samagotchi 0.156.0 → 0.158.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -0,0 +1,220 @@
1
+ // The history's shape: what a reader sees of the conversation, as a list of
2
+ // small records, so a turn drawn live and the same turn after a reload can be
3
+ // compared (live is the truth: a reload that reads otherwise is the bug).
4
+ // A pure DOM read with no app imports: the e2e reload checks
5
+ // (spec/e2e/support/history_shape.js) import it into the page, and chi web's
6
+ // opt-in reload check (reload_check.js) uses the same normalizer and
7
+ // allowlist.
8
+ //
9
+ // historyShape(historyEl) → [{kind, cls, text}], in order: each child of
10
+ // #history, and inside a turn block each step and each step's rows and
11
+ // notices. Kinds come from KINDS only (the server logs them, nothing else).
12
+ // `cls` is a kind's own modifier (a row's status, a warn notice, a report
13
+ // reply), `text` what it says, whitespace collapsed. Left out: lit's
14
+ // comments, the copy button, the quiet-stretch dividers (`.time-gap`), and
15
+ // view state (folds, stacks, the motion classes): nothing a reader would
16
+ // call a different turn.
17
+ //
18
+ // compareShapes(a, b, {allow}) → {same, kinds, excused, explained}: the two lists aligned
19
+ // (their longest common run), each leftover item, or pair of same-kind
20
+ // items at one place, a difference, unless an ACCEPTED entry named in
21
+ // +allow+ matches it. `kinds`: the differing items' kinds, sorted, unique.
22
+
23
+ // The kinds a shape item can have (a closed list: reload_check's log line
24
+ // carries only these, and chi web drops any other token).
25
+ export const KINDS = Object.freeze([
26
+ "prompt", "steered", "note", "command", "recap", "block", "step", "steer", "row", "notice", "card", "question",
27
+ "thinking", "answer", "empty-answer", "timing", "cancel", "failed", "other",
28
+ ]);
29
+
30
+ // The live/reload divergences that stay, each with why: the deliberate ones
31
+ // of section 1 of ~/.claude/plans/arch-turn-records.md (the bugs turn
32
+ // drawings closed are gone: a drawn turn reloads as live drew it). A
33
+ // difference one of them matches is not one: +a+ live, +b+ reloaded (either
34
+ // may be null: the item is on one side only), +shapes+ the two whole lists
35
+ // ({live, reloaded}). A caller names the ones its scenario has (`allow`);
36
+ // the opt-in reload check allows none and logs which would excuse a
37
+ // difference (`excused`).
38
+ export const ACCEPTED = Object.freeze([
39
+ {
40
+ id: "command-bubble",
41
+ reason: "a command's reply bubble (/help, /model, a queued one) is saved nowhere: a reload drops it "
42
+ + "(deliberate until a display journal; a !cmd is saved and stays)",
43
+ match: (a, b) => b === null && a?.kind === "command",
44
+ },
45
+ {
46
+ id: "edit-mark",
47
+ reason: "a reloaded row shows the ✂ mark an LLM context edit later saved on its output: later state, "
48
+ + "an overlay as a card's latest body is",
49
+ match: (a, b) => a?.kind === "row" && b?.kind === "row" && sameBut(a, b, "edit"),
50
+ },
51
+ ]);
52
+
53
+ function sameBut(a, b, key) {
54
+ const strip = ({ [key]: _drop, ...rest }) => rest;
55
+ return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
56
+ }
57
+
58
+ // What a reader reads of +el+: its text, without the copy button and the
59
+ // time dividers, whitespace collapsed.
60
+ function textOf(el) {
61
+ if (!el) return "";
62
+ const copy = el.cloneNode(true);
63
+ copy.querySelectorAll(".copy-btn, .time-gap").forEach((node) => node.remove());
64
+ return copy.textContent.replace(/\s+/g, " ").trim();
65
+ }
66
+
67
+ function item(kind, text = "", cls = "") {
68
+ return { kind, cls, text };
69
+ }
70
+
71
+ // A tool row: its status mark, then tool, title, the word an exception
72
+ // shows, its duration (formatDuration's text); its output, its diff's
73
+ // summary and its ✂ mark.
74
+ function rowItem(el) {
75
+ const status = [...(el.querySelector(".activity-status")?.classList || [])].filter((c) => c !== "activity-status").join(" ");
76
+ const text = [".activity-tool", ".activity-params", ".activity-state", ".activity-duration"]
77
+ .map((sel) => textOf(el.querySelector(`:scope > ${sel}`))).filter(Boolean).join(" · ");
78
+ const row = item("row", text, status);
79
+ // Its output as the hover has it (the inline text is its start or end),
80
+ // and the summary of an edit's diff.
81
+ const output = el.querySelector(":scope > .activity-output")?.getAttribute("title");
82
+ if (output) row.output = output;
83
+ const diff = textOf(el.querySelector(":scope > .activity-diff > summary"));
84
+ if (diff) row.diff = diff;
85
+ const edit = textOf(el.querySelector(":scope > .activity-edit"));
86
+ if (edit) row.edit = edit;
87
+ return row;
88
+ }
89
+
90
+ function cardItem(el) {
91
+ return item("card", `${el.dataset.cardId || ""} ${textOf(el.querySelector(".card-title"))}`.trim());
92
+ }
93
+
94
+ function questionItem(el) {
95
+ return item("question", textOf(el.querySelector(":scope > summary")));
96
+ }
97
+
98
+ function noticeItem(el) {
99
+ return item("notice", textOf(el), el.classList.contains("warn") ? "warn" : "");
100
+ }
101
+
102
+ // A step's own items, in order: the steers it answered, then its rows,
103
+ // notices and cards.
104
+ function stepItems(gen) {
105
+ const out = [item("step", textOf(gen.querySelector(":scope > summary")))];
106
+ for (const steer of gen.querySelectorAll(":scope > .steer-row")) out.push(item("steer", textOf(steer.querySelector("summary"))));
107
+ for (const el of gen.querySelectorAll(":scope > .activity-body > *")) {
108
+ if (el.matches(".activity-row")) out.push(rowItem(el));
109
+ else if (el.matches(".hook-notice")) out.push(noticeItem(el));
110
+ else if (el.matches(".plugin-card")) out.push(cardItem(el));
111
+ else if (el.matches(".bubble.question")) out.push(questionItem(el));
112
+ else out.push(otherItem(el));
113
+ }
114
+ return out;
115
+ }
116
+
117
+ // The classes that are view state, not what the element is.
118
+ const VIEW_CLASSES = new Set(["open", "folded", "unfolded", "outline", "live", "done", "entering", "card-updated",
119
+ "stack-hidden", "stack-swapped", "stack-view", "streaming"]);
120
+
121
+ function otherItem(el) {
122
+ const cls = [...el.classList].filter((c) => !VIEW_CLASSES.has(c)).sort().join(" ");
123
+ return item("other", "", cls || el.tagName.toLowerCase());
124
+ }
125
+
126
+ // One child of #history as its items (a turn block's are several; a time
127
+ // divider none).
128
+ function childItems(el) {
129
+ if (el.matches(".time-gap, .copy-btn")) return [];
130
+ if (el.matches(".turn-work")) {
131
+ return [item("block", textOf(el.querySelector(":scope > summary .turn-steps"))),
132
+ ...[...el.querySelectorAll(":scope > .gen")].flatMap(stepItems)];
133
+ }
134
+ if (el.matches(".bubble.user")) {
135
+ const kind = el.classList.contains("steered") ? "steered" : "prompt";
136
+ return [item(kind, textOf(el.querySelector(":scope > .user-message")))];
137
+ }
138
+ if (el.matches(".bubble.note")) return [item("note", textOf(el))];
139
+ if (el.matches(".bubble.command")) return [item("command", textOf(el.querySelector(".command-line")))];
140
+ if (el.matches(".bubble.recap")) return [item("recap", textOf(el.querySelector(".recap-body")))];
141
+ if (el.matches(".empty-answer")) return [item("empty-answer", textOf(el))];
142
+ if (el.matches(".hook-notice")) return [noticeItem(el)];
143
+ if (el.matches(".bubble.thinking")) return [item("thinking")];
144
+ if (el.matches(".bubble.output")) return [item("answer", textOf(el), el.classList.contains("report-reply") ? "report-reply" : "")];
145
+ if (el.matches(".turn-timing")) return [item("timing", textOf(el))];
146
+ if (el.matches(".bubble.cancel")) return [item(el.classList.contains("stopped") ? "cancel" : "failed", textOf(el))];
147
+ if (el.matches(".bubble.question")) return [questionItem(el)];
148
+ if (el.matches(".plugin-card")) return [cardItem(el)];
149
+ if (el.matches(".steer-row")) return [item("steer", textOf(el.querySelector("summary")))];
150
+ return [otherItem(el)];
151
+ }
152
+
153
+ // +root+'s shape: #history, or a fragment of a turn's elements (the reload
154
+ // check's offscreen render), or a list of elements.
155
+ export function historyShape(root) {
156
+ const els = Array.isArray(root) ? root : [...(root?.children || [])];
157
+ return els.flatMap(childItems);
158
+ }
159
+
160
+ const key = (it) => JSON.stringify(it);
161
+
162
+ // The longest common run of +a+ and +b+ by their items' text: the index
163
+ // pairs, in order.
164
+ function commonRun(a, b) {
165
+ const n = a.length;
166
+ const m = b.length;
167
+ const len = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
168
+ for (let i = n - 1; i >= 0; i--) {
169
+ for (let j = m - 1; j >= 0; j--) {
170
+ len[i][j] = key(a[i]) === key(b[j]) ? len[i + 1][j + 1] + 1 : Math.max(len[i + 1][j], len[i][j + 1]);
171
+ }
172
+ }
173
+ const pairs = [];
174
+ let i = 0;
175
+ let j = 0;
176
+ while (i < n && j < m) {
177
+ if (key(a[i]) === key(b[j])) { pairs.push([i, j]); i++; j++; } else if (len[i + 1][j] >= len[i][j + 1]) i++; else j++;
178
+ }
179
+ return pairs;
180
+ }
181
+
182
+ // The differences of +a+ and +b+: between two common items, a same-kind pair
183
+ // at one place is one change ([a, b]), the rest one-sided ([a, null],
184
+ // [null, b]).
185
+ function differences(a, b) {
186
+ const out = [];
187
+ const gap = (as, bs) => {
188
+ const rest = [...bs];
189
+ for (const x of as) {
190
+ const at = rest.findIndex((y) => y.kind === x.kind);
191
+ if (at >= 0) out.push([x, rest.splice(at, 1)[0]]);
192
+ else out.push([x, null]);
193
+ }
194
+ rest.forEach((y) => out.push([null, y]));
195
+ };
196
+ let i = 0;
197
+ let j = 0;
198
+ for (const [pi, pj] of [...commonRun(a, b), [a.length, b.length]]) {
199
+ gap(a.slice(i, pi), b.slice(j, pj));
200
+ i = pi + 1;
201
+ j = pj + 1;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ // +a+ (live) against +b+ (reloaded), +allow+ the ids of the ACCEPTED entries
207
+ // that may explain a difference. Besides the verdict on what is left: which
208
+ // ACCEPTED entries (any, allowed or not) would excuse a difference left
209
+ // (`excused`, sorted ids) and whether every one left has such an entry
210
+ // (`explained`): the opt-in reload check allows none and logs these, so each
211
+ // class is counted apart.
212
+ export function compareShapes(a, b, { allow = [] } = {}) {
213
+ const accepted = ACCEPTED.filter((entry) => allow.includes(entry.id));
214
+ const shapes = { live: a, reloaded: b };
215
+ const left = differences(a, b).filter(([x, y]) => !accepted.some((entry) => entry.match(x, y, shapes)));
216
+ const kinds = [...new Set(left.map(([x, y]) => (x || y).kind))].sort();
217
+ const excusing = left.map(([x, y]) => ACCEPTED.filter((entry) => entry.match(x, y, shapes)).map((entry) => entry.id));
218
+ const excused = [...new Set(excusing.flat())].sort();
219
+ return { same: left.length === 0, kinds, excused, explained: excusing.every((ids) => ids.length > 0), differences: left };
220
+ }
@@ -1488,6 +1488,11 @@ body.has-session #newBtn .brand-new{display:inline}
1488
1488
  .project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
1489
1489
  .project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
1490
1490
  .project-error{color:var(--warn-text)}
1491
+ .project-picker .project-new{width:100%;text-align:left;background:var(--bg-inset);color:var(--accent)}
1492
+ .project-create{display:grid;gap:8px;padding:8px 2px}
1493
+ .project-create label{color:var(--text-muted);font-size:12px}
1494
+ .project-create input{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset);color:var(--text)}
1495
+ .project-create-actions{display:flex;justify-content:flex-end;gap:8px}
1491
1496
  body.has-session #scope .scope-where{display:none}
1492
1497
  /* A phone has no room for both; the hidden strip's pill (count, live) wins. */
1493
1498
  @media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
@@ -1780,7 +1785,7 @@ a{color:var(--accent)}
1780
1785
  @media (max-width:600px),(pointer:coarse){
1781
1786
  #composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
1782
1787
  .rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ws-add-in,.rename-input,
1783
- #modelSearch{font-size:16px}
1788
+ #modelSearch,.project-create input{font-size:16px}
1784
1789
  }
1785
1790
  </style>
1786
1791
  </head>
@@ -24,10 +24,19 @@ function projectRow(row) {
24
24
  </a>`;
25
25
  }
26
26
 
27
+ function projectParent(rootLabel) {
28
+ if (typeof rootLabel !== "string" || !rootLabel) return "~/";
29
+ const slash = rootLabel.lastIndexOf("/");
30
+ if (slash < 0) return "~/";
31
+ const parent = rootLabel.slice(0, slash);
32
+ return parent ? `${parent.replace(/\/$/, "")}/` : "/";
33
+ }
34
+
27
35
  // +rows+ is the API's ordered list. When there is no project scope, the
28
36
  // all-projects row is current; a bad ?dir= has no current project to mark.
29
37
  export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
30
- allCurrent = false } = {}) {
38
+ allCurrent = false, createOpen = false, createPath = "~/", creating = false,
39
+ createError = "", onPathInput = () => {} } = {}) {
31
40
  const allHref = allScopeHref("", currentDir);
32
41
  return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
33
42
  data-act="close" aria-label="Close">×</button></div>
@@ -39,7 +48,15 @@ export function pickerTemplate({ rows = [], loading = false, error = "", current
39
48
  <span class="project-main"><span class="project-name">All projects</span
40
49
  ><span class="project-hint">Every session</span></span>
41
50
  ${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
42
- </a>
51
+ </a>${createOpen ? html`<form class="project-create" aria-busy=${creating}>
52
+ <label for="projectCreatePath">Folder path</label>
53
+ <input id="projectCreatePath" name="dir" type="text" autocomplete="off" .value=${createPath}
54
+ aria-invalid=${createError ? "true" : "false"} aria-describedby=${createError ? "projectCreateError" : nothing}
55
+ @input=${onPathInput}>
56
+ ${createError ? html`<div class="project-note project-error" id="projectCreateError" role="alert">${createError}</div>` : nothing}
57
+ <div class="project-create-actions"><button type="submit">${creating ? "Creating…" : "Create"}</button>
58
+ <button type="button" class="ghost" data-act="cancel-new">Cancel</button></div>
59
+ </form>` : html`<button type="button" class="project-row project-new" data-act="new-project">New project…</button>`}
43
60
  </nav>`;
44
61
  }
45
62
 
@@ -51,6 +68,12 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
51
68
  let error = "";
52
69
  // Only the newest read is drawn; a close drops the one out (latest.js).
53
70
  const latest = createLatest();
71
+ // Creating a project must not invalidate an in-flight list read.
72
+ const createLatestAnswer = createLatest();
73
+ let createOpen = false;
74
+ let createPath = "~/";
75
+ let creating = false;
76
+ let createError = "";
54
77
 
55
78
  const currentDir = () => scopeError() ? null : scope.dir || null;
56
79
  const paint = (focus = false) => {
@@ -58,6 +81,8 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
58
81
  render(pickerTemplate({
59
82
  rows, loading, error, currentDir: currentDir(),
60
83
  allCurrent: !scope.dir && !failedScope,
84
+ createOpen, createPath, creating, createError,
85
+ onPathInput: (event) => { createPath = event.currentTarget.value; },
61
86
  }), shell.pop);
62
87
  if (focus) {
63
88
  const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
@@ -89,11 +114,53 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
89
114
  if (!fresh()) return;
90
115
  loading = false;
91
116
  if (shell.isOpen()) {
92
- paint(focus);
117
+ // An open form keeps the caret: a late list must not pull focus to a row.
118
+ paint(focus && !createOpen);
93
119
  shell.replace();
94
120
  }
95
121
  }
96
122
 
123
+ async function submitProject() {
124
+ if (creating) return;
125
+ const fresh = createLatestAnswer.begin();
126
+ creating = true;
127
+ createError = "";
128
+ paint();
129
+ shell.replace();
130
+ try {
131
+ const answer = await api("/api/projects", { method: "POST", body: JSON.stringify({ dir: createPath }) });
132
+ if (!fresh()) return;
133
+ win.location.assign(projectScopeHref(answer.dir, ""));
134
+ } catch (e) {
135
+ if (!fresh()) return;
136
+ createError = e?.body?.detail || e?.message || String(e);
137
+ creating = false;
138
+ paint();
139
+ shell.replace();
140
+ }
141
+ }
142
+
143
+ function openCreateForm() {
144
+ createOpen = true;
145
+ createPath = projectParent(rows.find((row) => row.default)?.root_label);
146
+ createError = "";
147
+ creating = false;
148
+ paint();
149
+ shell.replace();
150
+ const input = shell.pop.querySelector("#projectCreatePath");
151
+ input?.focus();
152
+ input?.setSelectionRange(input.value.length, input.value.length);
153
+ }
154
+
155
+ function closeCreateForm() {
156
+ createLatestAnswer.drop();
157
+ createOpen = false;
158
+ creating = false;
159
+ createError = "";
160
+ paint();
161
+ shell.replace();
162
+ }
163
+
97
164
  const shell = createChipPopover({
98
165
  id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
99
166
  summary: () => ({}), sheet: true, label: "Projects", doc, win,
@@ -101,10 +168,25 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
101
168
  rows = [];
102
169
  loading = true;
103
170
  error = "";
171
+ createOpen = false;
172
+ createPath = "~/";
173
+ creating = false;
174
+ createError = "";
104
175
  paint();
105
176
  void load(true);
106
177
  },
107
- onClose: () => { latest.drop(); },
178
+ onSubmit: () => { void submitProject(); },
179
+ onAct: (name) => {
180
+ if (name === "new-project") openCreateForm();
181
+ else if (name === "cancel-new") closeCreateForm();
182
+ },
183
+ onClose: () => {
184
+ latest.drop();
185
+ createLatestAnswer.drop();
186
+ createOpen = false;
187
+ creating = false;
188
+ createError = "";
189
+ },
108
190
  });
109
191
 
110
192
  return { pop: shell.pop, isOpen: shell.isOpen, load };
@@ -0,0 +1,17 @@
1
+ // Where a saved recap goes by its stamp: the id of the last turn opening it
2
+ // covers (+openingId+, RecapPlace.opening_id: a prompt's turn_id, a wake
3
+ // note's, /continue no's reason line's own id), as a snapshot's card goes
4
+ // (card_stamps.js). It sits before the opening after that one's bubble, or
5
+ // at the end. No counting, so a rollback or a check-in can't put it a turn
6
+ // off. Without a stamp (an older worker's recap) or when the page doesn't
7
+ // draw that opening, it falls back to its count (format.js recapPlace).
8
+ // A new module: the static JS is unversioned (docs/internals/web-frontend.md).
9
+ import { stampedOpening } from "./card_stamps.js";
10
+ import { recapPlace } from "./format.js";
11
+
12
+ // The bubble the recap goes before (null: the end).
13
+ export function recapStampPlace(userBubbles, openingId, turnsSince) {
14
+ const at = stampedOpening(userBubbles, openingId);
15
+ if (at < 0) return recapPlace(userBubbles, turnsSince);
16
+ return userBubbles[at + 1] || null;
17
+ }
@@ -0,0 +1,71 @@
1
+ // The opt-in reload check (web.reload_check; <body data-reload-check="1">):
2
+ // once a finished turn's pieces have reached the history (the stage's
3
+ // hand-off), the page reads the session again as a reload would, draws the
4
+ // turn offscreen (turnHistoryNodes, a reload's renderer) and compares the
5
+ // two (history_shape.js) strictly: no ACCEPTED entry excuses a difference,
6
+ // so `same` is false for a known divergence too, and `excused` names the
7
+ // entries that would explain the differences (`explained`: all of them
8
+ // have one), so chi web's log counts each class apart (the bugs turn
9
+ // drawings close, the deliberate ones). It posts the counts, never
10
+ // content: {turn_id, same, kinds, excused, explained, drawn}; chi web logs
11
+ // them (reload_checked, reload_mismatch). Fire-and-forget: no toast, no
12
+ // store write, a failure is dropped.
13
+ //
14
+ // Cards are left out on both sides: a reload places them after the render
15
+ // (CardStore's stamps, cards_place.js), which the offscreen render doesn't
16
+ // run; the e2e reload checks compare them.
17
+ import { compareShapes, historyShape } from "./history_shape.js";
18
+ import { turnElements } from "./turn_range.js";
19
+
20
+ // The opening of a turn: its prompt, a wake's note, /continue no's line.
21
+ const OPENING = "[data-turn-id], [data-opening-id]";
22
+ // Not the turn's: the lines queued for after it (they open the next turns).
23
+ const NOT_THE_TURNS = ".bubble.user.queued, .bubble.command.queued";
24
+
25
+ function openingId(el) {
26
+ return el?.dataset?.turnId || el?.dataset?.openingId || null;
27
+ }
28
+
29
+ function withoutCards(shape) {
30
+ return shape.filter((item) => item.kind !== "card");
31
+ }
32
+
33
+ // @param enabled () => whether the check is on (the body's attribute)
34
+ // @param sessionId () => the open session's id
35
+ // @param running () => whether a turn runs now (liveTurn.running: a queued
36
+ // turn started streaming)
37
+ // @param read (id) => the session as a reload reads it (GET ?parts=1)
38
+ // @param render (data) => that read drawn as a reload draws it (a fragment)
39
+ // @param post (id, body) => the counts to chi web
40
+ export function createReloadCheck({ enabled, sessionId, running, read, render, post }) {
41
+ // The hand-off's pieces (stage_view.js onHandedOff), in their order;
42
+ // +immediate+: another turn took the stage at once.
43
+ async function handedOff({ pieces = [], immediate = false } = {}) {
44
+ if (!enabled() || immediate || running()) return;
45
+ const [opening, ...rest] = pieces;
46
+ const turnId = openingId(opening?.matches?.(OPENING) ? opening : null);
47
+ if (!turnId) return;
48
+ const id = sessionId();
49
+ try {
50
+ const data = await read(id);
51
+ if (sessionId() !== id || running()) return;
52
+ // The live pieces as they read now, after the read: a canceled or
53
+ // failed turn hands off at once, and its timing line takes the turn
54
+ // record's figure when its own re-read lands (refreshTimingFromSession),
55
+ // which started at the turn's end, before this one.
56
+ const live = withoutCards(historyShape(rest.filter((el) => !el.matches?.(NOT_THE_TURNS))));
57
+ const holder = document.createElement("div");
58
+ holder.append(render(data));
59
+ const reopened = [...holder.querySelectorAll(OPENING)].find((el) => openingId(el) === turnId);
60
+ const reloaded = reopened ? withoutCards(historyShape(turnElements(reopened, { container: holder }))) : [];
61
+ const { same, kinds, excused, explained } = reopened ? compareShapes(live, reloaded)
62
+ : { same: false, kinds: ["prompt"], excused: [], explained: false };
63
+ // Whether the read had a drawing for the turn (turn drawings, P2).
64
+ const drawn = !!data?.turn_drawings?.[turnId];
65
+ await post(id, { turn_id: turnId, same, kinds, excused, explained, drawn });
66
+ } catch (_) {
67
+ // Fire-and-forget: a failed read or post is no finding.
68
+ }
69
+ }
70
+ return { handedOff };
71
+ }
@@ -8,6 +8,7 @@ import { steerSender } from "./format.js";
8
8
  import { hookNoticeLabel } from "./turn_events.js";
9
9
  import { askingRefs, askingText } from "./turn_model.js";
10
10
  import { plainLine } from "./plain_line.js";
11
+ import { turnPieces } from "./turn_pieces.js";
11
12
 
12
13
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
13
14
  const EDITS = new Set(["edit", "write", "memory_write"]);
@@ -225,12 +226,13 @@ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
225
226
  return { due: nowMs - since >= QUIET_MS, quietSinceMs: since };
226
227
  }
227
228
 
228
- // A handed-off turn's nodes in the order the turn block leaves them: the
229
- // prompt bubble (none for a reminder turn), the block, the extras in arrival
230
- // order, the answer's pieces, the timing line, the end line, the kept cards,
231
- // then the lines queued for after the turn (they run next).
229
+ // A handed-off turn's nodes in the order the turn block leaves them, the
230
+ // order a reload draws (turn_pieces.js turnPieces): the prompt bubble (none
231
+ // for a reminder turn), the block, the extras in arrival order, the answer's
232
+ // pieces, the timing line, the end line, the kept cards, then the lines
233
+ // queued for after the turn (they run next).
232
234
  export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [], queued = [] }) {
233
- return [prompt, block, ...extras, ...answer, timing, end, ...kept, ...queued].filter(Boolean);
235
+ return turnPieces({ prompt, block, merged: extras, answer, timing, end, kept, queued });
234
236
  }
235
237
 
236
238
  // The rows that wait for the running turn's end to run as their own: a
@@ -67,10 +67,11 @@ function readCollapsed() {
67
67
  // page's working set (working_set_view.js, web.working_set; null: off): its
68
68
  // rail in the live slots, its footprint at the hand-off. +onCancel+: the
69
69
  // status row's Stop turn (a promise of the cancel request; the button is
70
- // disabled until it settles); null: no Stop turn.
70
+ // disabled until it settles); null: no Stop turn. +onHandedOff+({pieces,
71
+ // immediate}): a turn's pieces reached the history (reload_check.js).
71
72
  export function createStageView({
72
73
  historyEl, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, taskStop = null,
73
- renderMarkdown, linkRefs, workingSet = null, onCancel = null,
74
+ renderMarkdown, linkRefs, workingSet = null, onCancel = null, onHandedOff = null,
74
75
  }) {
75
76
  // The dock becomes a clear column: the stage's card, then the composer's
76
77
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
@@ -709,7 +710,7 @@ export function createStageView({
709
710
  queued: [...queuedSlot.children],
710
711
  });
711
712
  const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
712
- folding = { pieces, block: blockEl, endKind, wasAtEnd: historyNearBottom(), animate };
713
+ folding = { pieces, block: blockEl, endKind, wasAtEnd: historyNearBottom(), animate, immediate };
713
714
  releaseTurn();
714
715
  if (!animate) return finishFold();
715
716
  stage.classList.add("leaving");
@@ -735,6 +736,9 @@ export function createStageView({
735
736
  }
736
737
  clearStage();
737
738
  if (fold.wasAtEnd) easeScrollTo(historyEl, () => historyEl.scrollHeight - historyEl.clientHeight, { reduce: !fold.animate || reducedMotion() });
739
+ // The turn's pieces are in the history now (the opt-in reload check,
740
+ // reload_check.js); +immediate+: another turn took the stage.
741
+ onHandedOff?.({ pieces: fold.pieces, immediate: fold.immediate });
738
742
  }
739
743
 
740
744
  function dropBare() {
@@ -257,9 +257,13 @@ export function createStreamHandlers(deps) {
257
257
  activityEnded();
258
258
  const text = failedTurnText(data);
259
259
  // Only a rolled-back turn's line waits for its prompt_restored.
260
- liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
260
+ const kept = Number.isInteger(data.kept_steps);
261
+ liveTurn.failedText = kept ? null : text;
261
262
  endTurnView("failed", text);
262
263
  setLiveStatus("idle");
264
+ // A kept turn's timing line from its record, as a canceled one's and a
265
+ // reload's (a rolled-back one is drawn again by prompt_restored's resync).
266
+ if (kept) refreshTimingFromSession();
263
267
  },
264
268
  // The rolled-back turn left the session: render it from the server again,
265
269
  // then show the prompt once more, marked failed (with why). This tab's own
@@ -355,7 +359,9 @@ export function createStreamHandlers(deps) {
355
359
  // Due reminders went into the turn (a reminder turn has no prompt bubble).
356
360
  reminder_injected: (data) => {
357
361
  gapBefore(data.at);
358
- addCommandBubble({ label: null, line: reminderText(data), text: "", at: data.at });
362
+ // Its turn's id: a resync that draws the turn from its drawing (which
363
+ // has the reminder) drops this kept bubble (commands.js).
364
+ addCommandBubble({ label: null, line: reminderText(data), text: "", at: data.at, turnId: liveTurn.timing.turnId || null });
359
365
  },
360
366
  // A context note joined the conversation (between turns; no turn runs).
361
367
  // A note is activity of its own: it starts nothing, so it ends there too.
@@ -1,4 +1,5 @@
1
1
  import { isShellCommandItem } from "./shell_command.js";
2
+ import { drawingTurn, drawnKind } from "./turn_drawing.js";
2
3
 
3
4
  export function formatDuration(milliseconds) {
4
5
  const ms = Number(milliseconds);
@@ -190,12 +191,35 @@ export function timedTurnIndexes(items) {
190
191
  // prompts, as timedTurnIndexes does. The
191
192
  // user's lines merged into the running turn (`merged`, the server's
192
193
  // Steer::INPUT_KIND) stay in it: the turn's `merged` list.
193
- export function turnGroups(items, timing) {
194
+ // +drawings+ (chi web's turn_drawings, {turn_id: {events, end}}): the items
195
+ // stamped with a drawn turn's id (`drawn`) are that turn's group (`drawn`,
196
+ // its `drawing`), whether or not they start with an opening (a reminder
197
+ // turn has none; a wake's note, outside the range, opens it by its id); its
198
+ // steps are the drawing's (turn_drawing.js), and an unstamped item after it
199
+ // starts no part of it.
200
+ export function turnGroups(items, timing, { drawings = null } = {}) {
194
201
  const groups = [];
195
202
  let turnIndex = -1;
196
203
  let turn = null;
197
204
  items.forEach((item, i) => {
198
- if (item?.role === "note" && !startsTurn(item)) {
205
+ const drawn = drawings && typeof item?.drawn === "string" && drawings[item.drawn] ? item.drawn : null;
206
+ const looseNote = item?.role === "note" && !startsTurn(item);
207
+ if (turn?.drawn && !drawn && !looseNote) turn = null;
208
+ // An opening just before the range with the drawn turn's id (a wake's
209
+ // note, added before the turn began): the drawn turn's own.
210
+ if (drawn && turn && !turn.drawn && turn.turnId === drawn && turn.texts.length === 0) turn.drawn = drawn;
211
+ if (drawn && turn?.drawn !== drawn && !looseNote) {
212
+ turnIndex += 1;
213
+ turn = { kind: "turn", turnIndex, turnId: drawn, drawn, user: startsTurn(item) ? i : null, texts: [], steers: [], merged: [] };
214
+ groups.push(turn);
215
+ if (startsTurn(item)) return;
216
+ } else if (drawn && turn?.drawn === drawn && startsTurn(item) && !item.merged && item.role === "user") {
217
+ // A second opening line of the drawn turn (a wake turn's report after
218
+ // its note): one of its lines, as live drew it.
219
+ turn.merged.push(i);
220
+ return;
221
+ }
222
+ if (looseNote) {
199
223
  groups.push({ kind: "note", i });
200
224
  } else if (isShellCommandItem(item)) {
201
225
  // A `!cmd`'s record: its bubble alone, between the turns.
@@ -219,6 +243,7 @@ export function turnGroups(items, timing) {
219
243
  });
220
244
  return groups.map((g) => {
221
245
  if (g.kind !== "turn") return g;
246
+ if (g.drawn) return drawnGroup(g, items, timing, drawings[g.drawn]);
222
247
  const group = withSteers(turnGroup(g, items, timing), g.steers, items);
223
248
  // A turn that ended with no answer: its notice (the item's index) in
224
249
  // the answer's place.
@@ -232,6 +257,22 @@ export function turnGroups(items, timing) {
232
257
  });
233
258
  }
234
259
 
260
+ // A drawn turn's group: its record by id, its steps the drawing's gens
261
+ // (the rows the working set and the footprint read, as live), its answer
262
+ // the last text of a completed turn that had one, and its items (the
263
+ // opening, merged lines, the empty answer) by index.
264
+ function drawnGroup(g, items, timing, drawing) {
265
+ const record = (timing?.turnRecords || []).find((r) => r.id === g.drawn) || null;
266
+ const steps = drawingTurn(drawing).gens.map((gen, k) => ({ i: null, iteration: k + 1, thinking: gen.thinking, tools: gen.tools }));
267
+ const last = g.texts[g.texts.length - 1];
268
+ const answered = drawnKind(drawing.end) === "completed" && g.emptyAnswer == null && last != null && String(items[last]?.content ?? "") !== "";
269
+ const group = { kind: "turn", turnIndex: g.turnIndex, user: g.user, turnId: g.drawn, drawn: g.drawn, drawing, record, steps,
270
+ texts: g.texts, answer: answered ? last : null, steers: [] };
271
+ if (g.emptyAnswer != null) group.emptyAnswer = g.emptyAnswer;
272
+ if (g.merged.length) group.merged = g.merged;
273
+ return group;
274
+ }
275
+
235
276
  // A turn's steers (a plugin's nudges) into its steps: each goes to the step
236
277
  // that answered it (`step`: the server counts the model messages since the
237
278
  // prompt, plus one). One whose step isn't there (the answer, which leaves