samagotchi 0.149.0 → 0.150.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +2 -2
  5. data/docs/cli.md +59 -24
  6. data/docs/configuration.md +5 -5
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bridge.md +8 -0
  9. data/docs/internals/bundles.md +42 -14
  10. data/docs/internals/objectives.md +7 -2
  11. data/docs/internals/web-frontend.md +1 -1
  12. data/docs/internals/web-push.md +2 -2
  13. data/docs/internals/working-set.md +5 -2
  14. data/docs/memory.md +47 -9
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +5 -4
  17. data/docs/testing.md +5 -0
  18. data/lib/samagotchi/atomic_file.rb +3 -0
  19. data/lib/samagotchi/bootstrap_command.rb +5 -2
  20. data/lib/samagotchi/bridge/card_store.rb +52 -7
  21. data/lib/samagotchi/bridge.rb +1 -1
  22. data/lib/samagotchi/bundle_command.rb +91 -68
  23. data/lib/samagotchi/bundler_env.rb +24 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  27. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  28. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  32. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/chi_home.rb +4 -2
  35. data/lib/samagotchi/chi_shim.rb +2 -1
  36. data/lib/samagotchi/child_env.rb +10 -3
  37. data/lib/samagotchi/engine.rb +1 -1
  38. data/lib/samagotchi/git_env.rb +43 -0
  39. data/lib/samagotchi/guardrails/context.rb +2 -0
  40. data/lib/samagotchi/guardrails/targets.rb +4 -1
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  42. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  43. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  45. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  46. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  47. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  49. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  50. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  51. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  53. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  54. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  55. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  56. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  57. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  58. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  59. data/lib/samagotchi/objectives_check.rb +37 -7
  60. data/lib/samagotchi/project_env.rb +3 -2
  61. data/lib/samagotchi/self_report.rb +2 -0
  62. data/lib/samagotchi/testing/boundary.rb +1 -0
  63. data/lib/samagotchi/testing/minitest.rb +3 -0
  64. data/lib/samagotchi/testing/rspec.rb +3 -0
  65. data/lib/samagotchi/testing/sandbox.rb +3 -1
  66. data/lib/samagotchi/tools/delegate.rb +2 -1
  67. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  68. data/lib/samagotchi/turn_flow.rb +3 -1
  69. data/lib/samagotchi/update_command.rb +76 -28
  70. data/lib/samagotchi/update_hint.rb +4 -1
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/auto_update.rb +8 -8
  73. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  74. data/lib/samagotchi/web/participant_view.rb +1 -1
  75. data/lib/samagotchi/web/public/app.js +5 -3
  76. data/lib/samagotchi/web/public/block_open.js +13 -0
  77. data/lib/samagotchi/web/public/bubbles.js +4 -2
  78. data/lib/samagotchi/web/public/cards_place.js +10 -5
  79. data/lib/samagotchi/web/public/details_motion.js +2 -1
  80. data/lib/samagotchi/web/public/index.html +18 -14
  81. data/lib/samagotchi/web/public/stage_model.js +5 -6
  82. data/lib/samagotchi/web/public/stage_view.js +8 -7
  83. data/lib/samagotchi/web/public/turn_block.js +34 -19
  84. data/lib/samagotchi/web/public/turn_model.js +1 -1
  85. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  86. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  87. data/lib/samagotchi/web/push/notifier.rb +7 -9
  88. data/lib/samagotchi/web/session_hub.rb +2 -1
  89. data/lib/samagotchi/web/update_ops.rb +2 -1
  90. metadata +8 -1
@@ -197,7 +197,7 @@ module Samagotchi
197
197
  end
198
198
 
199
199
  # A newer chi this didn't install, settled: a run of its own from here.
200
- # The terminal's table already told what `chi update` kept.
200
+ # The terminal's table already told what `chi update` merged or left behind.
201
201
  def adopt_installed
202
202
  return unless @restart == "idle" && @seen
203
203
 
@@ -215,10 +215,10 @@ module Samagotchi
215
215
  return Log.info(:web, "auto_update_deferred", version: version, why: result) if result.is_a?(Symbol)
216
216
 
217
217
  rows = result.rows
218
- record = AutoUpdateRecord.new(version: version, from: VERSION, at: @clock.call.iso8601, kept: kept_files(rows),
219
- phase: "installed")
218
+ record = AutoUpdateRecord.new(version: version, from: VERSION, at: @clock.call.iso8601, kept: files_in(rows, "kept"),
219
+ merged: files_in(rows, "merged"), phase: "installed")
220
220
  if result.done?
221
- Log.info(:web, "auto_update_installed", version: version, kept: record.kept.size)
221
+ Log.info(:web, "auto_update_installed", version: version, kept: record.kept.size, merged: record.merged.size)
222
222
  installed(record)
223
223
  else
224
224
  failed(record, rows)
@@ -232,7 +232,7 @@ module Samagotchi
232
232
  return carry_on(save(record)) unless @restart == "never"
233
233
 
234
234
  save(record.with(phase: "done", pushed: true))
235
- @hub.publish_notice(kind: "installed", version: record.version, kept: record.kept.size)
235
+ @hub.publish_notice(kind: "installed", version: record.version, kept: record.kept.size, merged: record.merged.size)
236
236
  end
237
237
 
238
238
  # The next step of a run on record.
@@ -328,7 +328,7 @@ module Samagotchi
328
328
  record = restart_workers(record) || record
329
329
  Log.info(:web, "auto_update_done", version: record.version, restarted: record.restarted, left: record.left)
330
330
  save(record.with(phase: record.left_to_try?(@clock.call, RETRY_FOR) ? RETRYING : "done", pushed: true))
331
- @hub.publish_notice(kind: "updated", version: record.version, kept: record.kept.size,
331
+ @hub.publish_notice(kind: "updated", version: record.version, kept: record.kept.size, merged: record.merged.size,
332
332
  restarted: record.restarted, left: record.left)
333
333
  end
334
334
 
@@ -366,8 +366,8 @@ module Samagotchi
366
366
  installed: gem_installed)
367
367
  end
368
368
 
369
- def kept_files(rows)
370
- rows.flat_map { |row| Array(row["kept"]).map(&:to_s) }.uniq
369
+ def files_in(rows, field)
370
+ rows.flat_map { |row| Array(row[field]).map(&:to_s) }.uniq
371
371
  end
372
372
  end
373
373
  end
@@ -13,8 +13,10 @@ module Samagotchi
13
13
  # +phase+: installed, workers, web (AutoUpdate::OPEN_PHASES), retrying,
14
14
  # done or failed. +restarted+ and +left+ count the stale workers (`workers` in
15
15
  # the file); +at+ is when the install ended, an ISO 8601 string.
16
- AutoUpdateRecord = Data.define(:version, :from, :at, :kept, :phase, :restarted, :left, :detail, :pushed) do
17
- def initialize(version:, phase:, from: nil, at: nil, kept: [], restarted: 0, left: 0, detail: nil, pushed: false)
16
+ # +kept+ and +merged+ name the edited memory files that stayed behind and
17
+ # that took the update (chi update --json's).
18
+ AutoUpdateRecord = Data.define(:version, :from, :at, :kept, :merged, :phase, :restarted, :left, :detail, :pushed) do
19
+ def initialize(version:, phase:, from: nil, at: nil, kept: [], merged: [], restarted: 0, left: 0, detail: nil, pushed: false)
18
20
  super
19
21
  end
20
22
 
@@ -30,14 +32,14 @@ module Samagotchi
30
32
 
31
33
  workers = hash["workers"].is_a?(Hash) ? hash["workers"] : {}
32
34
  new(version: version, phase: phase, from: hash["from"], at: hash["at"],
33
- kept: Array(hash["kept"]).grep(String), restarted: workers["restarted"].to_i, left: workers["left"].to_i,
35
+ kept: Array(hash["kept"]).grep(String), merged: Array(hash["merged"]).grep(String), restarted: workers["restarted"].to_i, left: workers["left"].to_i,
34
36
  detail: hash["detail"], pushed: hash["pushed"] == true)
35
37
  end
36
38
 
37
39
  # What goes in the file, for .from_h to read back.
38
40
  # @return [Hash{String => Object}]
39
41
  def to_store
40
- { "version" => version, "from" => from, "at" => at, "kept" => kept, "phase" => phase,
42
+ { "version" => version, "from" => from, "at" => at, "kept" => kept, "merged" => merged, "phase" => phase,
41
43
  "workers" => { "restarted" => restarted, "left" => left }, "detail" => detail,
42
44
  "pushed" => pushed ? true : nil }.compact
43
45
  end
@@ -85,7 +85,7 @@ module Samagotchi
85
85
  llm_context_edited merged_input objectives_updated participants_changed pending_input_merged plugin_init_finished
86
86
  plugin_init_started plugin_init_wait prompt_restored question question_relay reasoning_history_refused
87
87
  recap_ready reminder_injected sampling_refused session_end session_start side_usage steer_cut thinking_dropped
88
- thinking_refused tool_dispatch_completed tool_dispatch_started used_memories_updated working_set_updated
88
+ thinking_refused tool_dispatch_completed tool_dispatch_started turn_rolled_back used_memories_updated working_set_updated
89
89
  ].freeze
90
90
 
91
91
  # A merged input line's saved sources a participant sees: a person's
@@ -1188,8 +1188,9 @@ function renderSessionView(data, { kept = [] } = {}) {
1188
1188
  restoreCommandBubbles(historyEl, kept);
1189
1189
  // A failed turn's prompt went back to the user: shown after the
1190
1190
  // conversation, marked failed, as it was live (prompt_restored).
1191
- if (data.failed_turn) {
1192
- makeUserBubble(data.failed_turn.prompt, { state: "failed" }).dataset.failedTurn = "1";
1191
+ const failedPrompt = data.failed_turn ? makeUserBubble(data.failed_turn.prompt, { state: "failed" }) : null;
1192
+ if (failedPrompt) {
1193
+ failedPrompt.dataset.failedTurn = "1";
1193
1194
  addEndBubble(failedTurnText(data.failed_turn));
1194
1195
  }
1195
1196
  seedSeenFromDom();
@@ -1203,7 +1204,8 @@ function renderSessionView(data, { kept = [] } = {}) {
1203
1204
  // A live worker's last cards and between-turns notices, where they
1204
1205
  // arrived; the running turn's go into its step once it is drawn.
1205
1206
  const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
1206
- placeSnapshotCards(snapshotCards.placed);
1207
+ // Above a failed turn's prompt: it came after them, and it is no turn.
1208
+ placeSnapshotCards(snapshotCards.placed, { before: failedPrompt });
1207
1209
  // The recap saved with the session (also a stopped one's), else a live
1208
1210
  // worker's from before saved_recap existed. After the cards: the last
1209
1211
  // turn's check-in notice goes to the end, and the recap closes the page.
@@ -0,0 +1,13 @@
1
+ // Whether a finished turn's steps block stays open: a completed turn's closes
2
+ // to its summary line, whatever its steps said (opened, it shows the outline).
3
+ // A canceled or failed turn with an outline stays open: its last narrated step
4
+ // is the only text the reader gets. A "gone" end (the worker left) counts as
5
+ // completed: its partial answer bubble carries the text, and a reload, which
6
+ // can't tell it from one, draws the same. The live end, the stage's hand-off
7
+ // and a reload all ask here.
8
+ //
9
+ // A module of its own: static JS is cached at unversioned URLs, and a new
10
+ // export on an old module would blank the page.
11
+ export function openAfterEnd(kind, outlined) {
12
+ return !!outlined && (kind === "canceled" || kind === "failed");
13
+ }
@@ -26,8 +26,10 @@ export function removeQueuedBubble(bubble) {
26
26
  if (slot && slot.childElementCount === 0) slot.remove();
27
27
  }
28
28
 
29
- // A prompt that started a turn: not a line steered into a running one.
30
- export const TURN_PROMPT = ".bubble.user:not(.steered)";
29
+ // A prompt that started a turn: not a line steered into a running one, nor a
30
+ // failed turn's prompt, drawn again after the history (it went back to its
31
+ // sender: the session and the turn count have no such turn).
32
+ export const TURN_PROMPT = ".bubble.user:not(.steered):not(.failed)";
31
33
 
32
34
  // The history's turn prompts, oldest first (what turns_since counts).
33
35
  export function turnPromptBubbles(historyEl) {
@@ -46,7 +46,7 @@ export function turnAnswerEl(turnEls) {
46
46
  // (bubbles.js createBubbles)
47
47
  // @param followWokenWorker () follows the worker a card's command woke
48
48
  // @param api {getSession, sendCommand} (data.js)
49
- // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
49
+ // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries, {before}),
50
50
  // refreshCardBodies(), onCardAction(event)}
51
51
  const HELD_UNTIL_ANSWERED = /^\/objectives --card /;
52
52
 
@@ -63,9 +63,12 @@ export function createCardsPlace({
63
63
  // running turn's card as a row of its step; else before +before+ or at the
64
64
  // end of the history). A running turn's card with actions asks the user
65
65
  // something (check-in's Nudge / Keep going / Stop): it goes under the turn's
66
- // block, not into a step that collapses when the next one starts.
66
+ // block, not into a step that collapses when the next one starts. A turn
67
+ // still sliding away arrives first: its list of pieces holds this card's
68
+ // element, and an update may swap that (drawCard, a notice to a card).
67
69
  function upsertCard(card, { before = null } = {}) {
68
70
  if (!card || !card.id) return;
71
+ turnDom.settleFold?.();
69
72
  const existing = cardElement(card.id);
70
73
  if (existing) {
71
74
  const drawn = drawCard(existing, card);
@@ -81,11 +84,13 @@ export function createCardsPlace({
81
84
  }
82
85
 
83
86
  // A snapshot's cards and notices, each above the first turn after it; a
84
- // turn's own notice where it showed live (placeTurnNotice).
85
- function placeSnapshotCards(entries) {
87
+ // turn's own notice where it showed live (placeTurnNotice). +end+: what
88
+ // closes the history below them (a failed turn's prompt), for those with
89
+ // no turn after them.
90
+ function placeSnapshotCards(entries, { before: end = null } = {}) {
86
91
  const users = turnPromptBubbles(historyEl);
87
92
  for (const entry of entries) {
88
- const before = cardPlace(users, entry);
93
+ const before = cardPlace(users, entry) || end;
89
94
  if (isCard(entry)) upsertCard(entry, { before });
90
95
  else if (entry.type === "question") placeResolvedQuestion(entry, before);
91
96
  else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
@@ -56,7 +56,8 @@ export function installDetailsMotion({ root, selector, motion = canAnimate }) {
56
56
  const summary = e.target.closest?.("summary");
57
57
  const details = summary?.parentElement;
58
58
  if (!details || !details.matches?.(selector) || summary.parentElement !== details) return;
59
- if (e.target.closest("a, button, input, label") !== null && e.target !== summary) return;
59
+ // A live part of the summary (a steps block's footprint opens its own view).
60
+ if (e.target.closest("a, button, input, label, .ws-foot") !== null && e.target !== summary) return;
60
61
  if (!motion(details)) return;
61
62
  e.preventDefault();
62
63
  if (busy.has(details)) return;
@@ -346,10 +346,16 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
346
346
  .thinking-body{margin-top:6px;font-size:13px;color:var(--text-3);white-space:pre-wrap;word-break:break-word;font-style:italic;max-height:240px;overflow:auto}
347
347
  /* The turn block (the stage's cloud, the history): one block per turn, its steps as collapsed rows, the live one open on top. */
348
348
  .turn-work{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;background:var(--bg-panel);padding:8px 12px;font-size:14px}
349
- .turn-work>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
349
+ .turn-work>summary{position:relative;padding-left:13px;cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
350
350
  .turn-work>summary::-webkit-details-marker{display:none}
351
- .turn-work>summary::before{content:"▸ ";color:var(--accent)}
352
- .turn-work[open]>summary::before{content:"▾ "}
351
+ .turn-work>summary::before{content:"▸";position:absolute;left:0;color:var(--accent)}
352
+ /* The summary's line: flex on this span, not on <summary> (WebKit's flex on a summary has had quirks). The chevron and the steps toggle the block, the footprint opens the turn's working set (working_set_view.js footClick). */
353
+ .tw-sum{display:flex;align-items:baseline;gap:10px;min-width:0}
354
+ /* The count gives way before the footprint, which is what the line is for. */
355
+ .ws-foot~.turn-steps{margin-left:auto;flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12px;font-weight:500;color:var(--text-muted);white-space:nowrap}
356
+ /* A phone has no count to tap, and the chevron is then the only way to the steps: its hit area spreads over the block's padding, the glyph stays where it is. */
357
+ @media (max-width:600px){.ws-foot~.turn-steps{display:none}.turn-work>summary::before{left:-12px;top:-8px;width:36px;box-sizing:border-box;padding:8px 0 8px 12px}}
358
+ .turn-work[open]>summary::before{content:"▾"}
353
359
  /* Closed, a block keeps its box (the bar, the border, the width): a click changes its height only (details_motion.js); thinking and the recap the same. */
354
360
  /* A step's body (thinking, narration, rows, cards) sits --step-indent right of its summary, so the thinking
355
361
  chevron and the row marks share one column under the step's own chevron. The summary takes it back with a
@@ -399,11 +405,11 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
399
405
  .gen:not(.live) .gen-text{font-size:14px;color:var(--text-2)}
400
406
  .gen-text:empty{display:none}
401
407
  .gen .activity-body{margin-top:6px}
402
- /* One gen, no thinking, no tool rows: nothing to stack yet, so the narration streams like a plain answer bubble. */
403
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)){border:0;padding:0;background:transparent}
404
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen>summary{display:none}
405
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen{margin:0;padding:0;border:0;background:transparent}
406
- .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body)) .gen-text{margin:0;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:12px 14px;opacity:.95}
408
+ /* One gen, no thinking, no tool rows: nothing to stack yet, so the narration streams like a plain answer bubble. A step that closed with text (a line steered in after it, its answer gone from the block) is a row: its summary is the way to its text. */
409
+ .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)){border:0;padding:0;background:transparent}
410
+ .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text))>summary,.turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen>summary{display:none}
411
+ .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen{margin:0;padding:0;border:0;background:transparent}
412
+ .turn-work:not(:has(.gen~.gen,.thinking-body,.activity-body,.gen:not(.live)>.gen-text)) .gen-text{margin:0;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;padding:12px 14px;opacity:.95}
407
413
  @media(max-width:600px){.gen{--step-indent:8px}}
408
414
  /* A question card asks for input like the composer card, so it wears the same glass (the recipe above); only its warm tint is its own. */
409
415
  .bubble.question{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg));padding:10px 12px}
@@ -978,11 +984,11 @@ body.working-set .ts-live{position:relative;padding-right:264px}
978
984
  }
979
985
  body.working-set .turn-stage.waiting .ts-live{min-height:calc(var(--ts-live-h) + var(--ws-row-h,0px))}
980
986
  body.working-set .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(calc(var(--ts-live-h) + var(--ws-row-h,0px)),var(--live-held,0px))}
981
- /* A finished turn's footprint, under its closed block (working_set_view.js): one faint line; a click opens its list. */
982
- .ws-foot{display:block;width:fit-content;max-width:calc(100% - 13px);box-sizing:border-box;margin:-4px 0 0 13px;padding:0 6px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:18px;color:var(--text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
983
- @media(hover:hover){.ws-foot:hover{color:var(--text-muted);background:var(--bg-hover)}}
987
+ /* A finished turn's footprint, its block's summary line (working_set_view.js): a click opens its list. */
988
+ .ws-foot{flex:0 1 auto;min-width:0;padding:0 6px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
989
+ @media(hover:hover){.ws-foot:hover{background:var(--bg-hover)}}
984
990
  .ws-foot:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
985
- .ws-foot .w{font-family:ui-sans-serif,system-ui,sans-serif}
991
+ .ws-foot .w{font-family:ui-sans-serif,system-ui,sans-serif;font-weight:600;color:var(--text-muted)}
986
992
  .ws-foot .sep{color:var(--border-strong)}
987
993
  .ws-foot .e{color:var(--accent)}
988
994
  .ws-foot .k{color:var(--think-text)}
@@ -1327,8 +1333,6 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1327
1333
  .us-row-head span{flex:1 1 0;min-width:0;text-align:right;color:var(--text-2);font-size:12px;overflow-wrap:anywhere}
1328
1334
  .us-detail{margin-top:4px;color:var(--text-muted);font-size:12px;overflow-wrap:anywhere}
1329
1335
  .us-kept{margin-top:6px;color:var(--warn-text);font-size:12px}
1330
- .us-kept ul{margin:3px 0 0;padding-left:18px;color:var(--text-2)}
1331
- .us-kept li{overflow-wrap:anywhere}
1332
1336
  .us-foot{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
1333
1337
  @media(max-width:600px){#updateSheet{position:fixed;inset:auto 8px 8px;width:auto;max-width:none;max-height:min(80dvh,calc(100dvh - 16px));margin:0}}
1334
1338
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -226,12 +226,11 @@ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
226
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, its working set's
230
- // footprint line (web.working_set), the extras in arrival order, the
231
- // answer's pieces, the timing line, the end line, the kept cards, then the
232
- // lines queued for after the turn (they run next).
233
- export function handOffOrder({ prompt = null, block = null, footprint = null, extras = [], answer = [], timing = null, end = null, kept = [], queued = [] }) {
234
- return [prompt, block, footprint, ...extras, ...answer, timing, end, ...kept, ...queued].filter(Boolean);
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).
232
+ 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
234
  }
236
235
 
237
236
  // The rows that wait for the running turn's end to run as their own: a
@@ -15,6 +15,7 @@
15
15
  // stage_frame.js's, and the in-use watch that times the hand-off is
16
16
  // stage_watch.js's.
17
17
  import { createTurnBlock } from "./turn_block.js";
18
+ import { openAfterEnd } from "./block_open.js";
18
19
  import { chipLabelText, flashOf, handOffDue, handOffOrder, headlineOf, isPlain, liveSlots, MARKS, miniToolText, placeFor, placeKind, QUEUED_CHILDREN, stepSlotText } from "./stage_model.js";
19
20
  import { createStageSlots, node } from "./stage_slots.js";
20
21
  import { createStageFrame, reducedMotion } from "./stage_frame.js";
@@ -687,7 +688,7 @@ export function createStageView({
687
688
  // the pieces arrive in the history and it follows a reader who was at its
688
689
  // end. Not with +immediate+ (another turn takes the stage at once), a
689
690
  // reduced motion, a hidden page or a collapsed stage: all at once.
690
- let folding = null; // { pieces, block, wasAtEnd, animate } while it slides
691
+ let folding = null; // { pieces, block, endKind, wasAtEnd, animate } while it slides
691
692
  function handOff({ immediate = false } = {}) {
692
693
  if (folding && immediate) finishFold();
693
694
  if (!holding || !endKind) return;
@@ -695,12 +696,12 @@ export function createStageView({
695
696
  view.chronological();
696
697
  const block = blockEl && blockEl.parentNode === cloudBody ? blockEl : null;
697
698
  // The turn's set joins the page's turns (a plain one's too: the turns
698
- // keep their places); its footprint goes under the block.
699
+ // keep their places); its footprint takes the block's summary line.
699
700
  const footprint = workingSet ? workingSet.handOff(view.current().turn, promptWords()) : null;
701
+ if (block && footprint) block.querySelector(":scope > summary > .tw-sum")?.prepend(footprint);
700
702
  const pieces = handOffOrder({
701
703
  prompt: promptSlot.firstElementChild,
702
704
  block,
703
- footprint: block ? footprint : null,
704
705
  extras: [...extras.children],
705
706
  answer: [...answer.children],
706
707
  timing: timingSlot.firstElementChild,
@@ -708,7 +709,7 @@ export function createStageView({
708
709
  queued: [...queuedSlot.children],
709
710
  });
710
711
  const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
711
- folding = { pieces, block: blockEl, wasAtEnd: historyNearBottom(), animate };
712
+ folding = { pieces, block: blockEl, endKind, wasAtEnd: historyNearBottom(), animate };
712
713
  releaseTurn();
713
714
  if (!animate) return finishFold();
714
715
  stage.classList.add("leaving");
@@ -721,9 +722,9 @@ export function createStageView({
721
722
  const fold = folding;
722
723
  if (!fold) return;
723
724
  folding = null;
724
- // Closed, unless it is an outline (turn_block.js outlineBlock): the
725
- // history reads its narrated steps without a click.
726
- if (fold.block) fold.block.open = fold.block.classList.contains("outline");
725
+ // The stage held it open; here it takes its place in the history:
726
+ // closed, unless the turn was cut short with an outline (block_open.js).
727
+ if (fold.block) fold.block.open = openAfterEnd(fold.endKind, fold.block.classList.contains("outline"));
727
728
  historyEl.querySelector(":scope > .hint")?.remove();
728
729
  for (const el of fold.pieces) {
729
730
  historyEl.appendChild(el);
@@ -17,14 +17,17 @@
17
17
  // walks childNodes (or reads firstChild/lastChild) must skip comments;
18
18
  // element queries, :empty and textContent are unaffected.
19
19
  //
20
- // A block that holds one gen with no thinking and no tool rows hides its
20
+ // A block that holds one live gen with no thinking and no tool rows hides its
21
21
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
22
- // removed at the end: the answer bubble takes its place.
22
+ // removed at the end: the answer bubble takes its place. A lone step that
23
+ // closed with text keeps its chrome: a line steered in after it left it alone
24
+ // in the block when the answer went, and its summary is the way to its text.
23
25
  import { authorBadge, PAIR } from "./participant_names.js";
24
26
  import { applyRowTemplate } from "./review_apply.js";
25
27
  import { html, nothing, render } from "./vendor/lit-html.js";
26
28
  import { answerRefsOf, setAnswerRefs } from "./answer_refs.js";
27
29
  import { bindRowRefs } from "./refs.js";
30
+ import { openAfterEnd } from "./block_open.js";
28
31
  import { leavesBlock } from "./card.js";
29
32
  import { failedTurnText, normalize, sourceLabel } from "./format.js";
30
33
  import { thumbsTemplate } from "./images.js";
@@ -132,7 +135,7 @@ function nodesOf(template) {
132
135
  // ---- A finished block's outline ----
133
136
 
134
137
  // A finished block as its outline (turn_model.js outlineRuns of +gens+, its
135
- // steps in order): open, each narrated step a one-line row as before, each
138
+ // steps in order): each narrated step a one-line row as before, each
136
139
  // run of steps that said nothing hidden behind one "N steps" fold that
137
140
  // shows them. The live turn's end and a reload call it alike. The folds sit
138
141
  // between the steps, not in them (`:scope > .gen` still counts steps), each
@@ -178,7 +181,6 @@ export function outlineBlock(blockEl, gens) {
178
181
  const step = e.target.closest?.(".gen.folded");
179
182
  if (step?.parentElement === blockEl) unfold.get(step)?.();
180
183
  });
181
- blockEl.open = true;
182
184
  return true;
183
185
  }
184
186
 
@@ -214,7 +216,7 @@ export function createTurnBlock({
214
216
  let turn = null;
215
217
  let blockEl = null;
216
218
  let blockSummaryEl = null;
217
- let blockToggled = false;
219
+ let blockStepsEl = null;
218
220
  // gen → a step's live state (newStep), its rendered nodes between
219
221
  // part.startNode and end.
220
222
  const genEls = new Map();
@@ -237,16 +239,20 @@ export function createTurnBlock({
237
239
  blockEl = document.createElement("details");
238
240
  blockEl.className = "turn-work";
239
241
  blockEl.open = true;
240
- blockToggled = false;
241
242
  blockSummaryEl = document.createElement("summary");
243
+ const line = document.createElement("span");
244
+ line.className = "tw-sum";
245
+ blockStepsEl = document.createElement("span");
246
+ blockStepsEl.className = "turn-steps";
247
+ line.appendChild(blockStepsEl);
248
+ blockSummaryEl.appendChild(line);
242
249
  blockEl.appendChild(blockSummaryEl);
243
- blockSummaryEl.addEventListener("click", () => { blockToggled = true; });
244
250
  updateBlockSummary();
245
251
  blockHost(blockEl);
246
252
  }
247
253
 
248
254
  function updateBlockSummary() {
249
- if (blockSummaryEl && turn) blockSummaryEl.textContent = model.blockSummary(turn, { running: !turn.ended });
255
+ if (blockStepsEl && turn) blockStepsEl.textContent = model.blockSummary(turn, { running: !turn.ended });
250
256
  }
251
257
 
252
258
  // The step rendered again from its state (stepTemplate). Each live step
@@ -621,6 +627,7 @@ export function createTurnBlock({
621
627
  placeAnswer(pieces, { plain: true, blockEl });
622
628
  blockEl = null;
623
629
  blockSummaryEl = null;
630
+ blockStepsEl = null;
624
631
  } else {
625
632
  placeAnswer(bubble ? [...cards, bubble] : [...cards, ...(endNote ? [endNote] : [])], { plain: false, blockEl });
626
633
  }
@@ -649,11 +656,12 @@ export function createTurnBlock({
649
656
  if (blockEl) {
650
657
  updateBlockSummary();
651
658
  blockEl.classList.add("done");
652
- // The outline (turn_model.js outlineRuns): open on its narrated steps'
653
- // rows, as a reload draws it; a block with nothing said closes.
659
+ // The outline (turn_model.js outlineRuns), for when it is opened. Closed
660
+ // as a reload draws it, unless the turn was cut short (block_open.js).
661
+ // The stage decides again as the block reaches the history.
654
662
  chronological();
655
663
  const outlined = outlineBlock(blockEl, model.blockGens(turn));
656
- if (!blockToggled) blockEl.open = outlined;
664
+ blockEl.open = openAfterEnd(kind, outlined);
657
665
  for (const el of blockEl.querySelectorAll(".plugin-card")) {
658
666
  if (leavesBlock({ warn: el.classList.contains("warn"), kind })) kept.push(el);
659
667
  }
@@ -682,7 +690,7 @@ export function createTurnBlock({
682
690
  turn = null;
683
691
  blockEl = null;
684
692
  blockSummaryEl = null;
685
- blockToggled = false;
693
+ blockStepsEl = null;
686
694
  // Clear every ticker timer before the map goes (a live step mid-dwell).
687
695
  for (const entry of genEls.values()) stopTicker(entry);
688
696
  genEls.clear();
@@ -821,7 +829,7 @@ function failedLineTemplate(record) {
821
829
  // template, images.js thumbsTemplate). +workingSet+ (the stage's
822
830
  // web.working_set): each block stamped with its
823
831
  // turn's ordinal and its rows with theirs (working_set_view.js), and the
824
- // turn's footprint line after it.
832
+ // turn's footprint on its summary line.
825
833
  export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, now = Date.now() }) {
826
834
  const timingTemplate = (group) => {
827
835
  const record = group.record;
@@ -861,9 +869,11 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
861
869
  };
862
870
  const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
863
871
  const parts = [];
864
- // Each block's steps, in order: its outline once the nodes exist; and
865
- // its turn's place among the turns (the working set's stamp).
872
+ // Each block's steps, in order: its outline once the nodes exist; how its
873
+ // turn ended (whether it stays open); and its turn's place among the turns
874
+ // (the working set's stamp).
866
875
  const outlines = [];
876
+ const blockEnds = [];
867
877
  const blockTurns = [];
868
878
  let turnOrdinal = -1;
869
879
  const groups = turnGroups(items, timing);
@@ -903,10 +913,14 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
903
913
  // Its text knows its message's refs (a question's), as an answer does.
904
914
  at: group.steps[k].i,
905
915
  }));
906
- parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
916
+ // Its summary line: the working set's footprint, when the turn touched
917
+ // something, then the steps (the live block's, stage_view.js handOff).
918
+ const foot = workingSet ? footprintTemplate(groupItems(group, turnOrdinal), turnOrdinal) : nothing;
919
+ parts.push(html`<details class="turn-work done"><summary><span class="tw-sum">${foot}<span class="turn-steps">${model.blockSummary({ gens, ended: true })}</span></span></summary>${steps}</details>`);
907
920
  outlines.push(gens);
921
+ // No record: a turn saved before them, or running at the reload.
922
+ blockEnds.push(group.record?.status ?? "completed");
908
923
  blockTurns.push(turnOrdinal);
909
- if (workingSet) parts.push(footprintTemplate(groupItems(group, turnOrdinal), turnOrdinal));
910
924
  }
911
925
  // Steers with no step to sit in, or in a turn that shows no block: on
912
926
  // their own after the prompt.
@@ -941,9 +955,10 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
941
955
  parts.push(failedLineTemplate(group.record));
942
956
  }
943
957
  const out = nodesOf(parts);
944
- // A finished turn's block as the live end leaves it: its outline, open.
958
+ // A finished turn's block as the live end leaves it: its outline, closed
959
+ // unless the turn was cut short.
945
960
  const blocks = [...out.children].filter((el) => el.matches(".turn-work"));
946
- blocks.forEach((block, i) => outlineBlock(block, outlines[i] || []));
961
+ blocks.forEach((block, i) => { block.open = openAfterEnd(blockEnds[i], outlineBlock(block, outlines[i] || [])); });
947
962
  if (workingSet) stampHistory(blocks, blockTurns);
948
963
  // Each answer bubble and step text knows its message's refs
949
964
  // (answer_refs.js; the page pairs them after the render), the index
@@ -279,7 +279,7 @@ export function isNarrated(gen) {
279
279
  // them): each narrated step a row, each run of the others between (tool-only,
280
280
  // thinking-only) one fold ({fold: [indices]}), so every mid-turn text's first
281
281
  // line reads without a click and the steps that said nothing take one line.
282
- // [] when no step said anything: the block stays closed, as before.
282
+ // [] when no step said anything: nothing to outline.
283
283
  // A last run of one thinking-only step stays a row (no "· 1 step" at the end).
284
284
  export function outlineRuns(gens) {
285
285
  if (!gens.some(isNarrated)) return [];
@@ -1,12 +1,17 @@
1
1
  import { html, nothing, render } from "./vendor/lit-html.js";
2
2
 
3
+ // chi update's own words for the row (FileStanding owns what it says of a file).
4
+ // One that speaks of edited files runs long, so it gets the row's whole width.
3
5
  function installRow(row) {
4
- const kept = Array.isArray(row.kept) ? row.kept.map(String) : [];
6
+ const count = (list) => (Array.isArray(list) ? list.length : 0);
7
+ const behind = count(row.kept) > 0;
8
+ const long = behind || count(row.merged) > 0 || count(row.earlier) > 0;
9
+ const status = row.status || "working";
5
10
  const versions = [row.from, row.to].filter(Boolean).join(" → ");
6
11
  return html`<li class="us-row">
7
- <div class="us-row-head"><strong>${row.component || "chi update"}</strong><span>${row.status || "working"}</span></div>
12
+ <div class="us-row-head"><strong>${row.component || "chi update"}</strong>${long ? nothing : html`<span>${status}</span>`}</div>
8
13
  ${versions ? html`<div class="us-detail">${versions}</div>` : nothing}
9
- ${kept.length ? html`<div class="us-kept"><strong>Kept edited files:</strong><ul>${kept.map((file) => html`<li>${file}</li>`)}</ul></div>` : nothing}
14
+ ${long ? html`<div class=${behind ? "us-kept" : "us-detail"}>${status}</div>` : nothing}
10
15
  </li>`;
11
16
  }
12
17
 
@@ -95,17 +95,18 @@ function footTag(label) {
95
95
  return html` <button type="button" class="ws-step p" data-ws="jump" data-at=${atAttr(first.at)} title=${title}>${text}</button>`;
96
96
  }
97
97
 
98
- // The footprint line of a finished turn's +items+ (turn +ordinal+), nothing
99
- // for a turn that touched nothing: "touched web/public/ · 9 files · ◆ 3
100
- // memories s1–5 · +1 url · edited 2". A click opens the expanded view at
101
- // that turn; a step tag jumps to its call.
98
+ // The footprint of a finished turn's +items+ (turn +ordinal+), nothing for a
99
+ // turn that touched nothing: "touched web/public/ · 9 files · ◆ 3 memories
100
+ // s1–5 · +1 url · edited 2". It sits in the turn block's summary line, so a
101
+ // span (phrasing content). A click opens the expanded view at that turn; a
102
+ // step tag jumps to its call.
102
103
  export function footprintTemplate(items, ordinal) {
103
104
  const fp = footprint(items, BUDGETS.footprint);
104
105
  if (!fp) return nothing;
105
106
  const sep = html` <span class="sep">·</span> `;
106
107
  const parts = fp.labels.map((l, i) => html`${i ? sep : nothing}<span class=${`l ${l.kind}${l.state && l.state !== "live" ? ` ${l.state}` : ""}`} title=${labelTitle(l)}>${l.glyph ? html`<span class="k">${l.glyph}</span> ` : nothing}${labelText({ ...l, glyph: "" })}</span>${footTag(l)}`);
107
108
  const text = footprintText(items, BUDGETS.footprint);
108
- return html`<div class="ws-foot" role="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.stale ? html`${sep}<span class="s">${fp.stale} stale</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</div>`;
109
+ return html`<span class="ws-foot" role="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.stale ? html`${sep}<span class="s">${fp.stale} stale</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</span>`;
109
110
  }
110
111
 
111
112
  // What a footprint shows that can change after it is drawn: its items'
@@ -418,10 +419,12 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
418
419
  }
419
420
  });
420
421
 
421
- // A footprint: its step tag jumps, the rest opens that turn's view.
422
+ // A footprint: its step tag jumps, the rest opens that turn's view. Inside
423
+ // a <summary>, the click is cancelled so it doesn't toggle the block.
422
424
  function footClick(e) {
423
425
  const foot = e.target.closest?.(".ws-foot");
424
426
  if (!foot) return;
427
+ if (foot.closest("summary")) e.preventDefault();
425
428
  const tag = e.target.closest(".ws-step[data-at]");
426
429
  if (tag) {
427
430
  const at = parseAt(tag.dataset.at);
@@ -563,7 +566,7 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
563
566
  // The rail's first-sight times, for the popover's fresh rows.
564
567
  setSeen(fn) { seenAt = fn; },
565
568
  // The running turn hands off: its set joins the turns; its footprint
566
- // line (null: it touched nothing) goes after its block.
569
+ // (null: it touched nothing) goes into its block's summary.
567
570
  handOff(turn, prompt = "") {
568
571
  const ordinal = turns.length;
569
572
  const items = itemsOfTurn(turn, ordinal);
@@ -5,6 +5,7 @@ require "uri"
5
5
  require "time"
6
6
  require_relative "attention"
7
7
  require_relative "../../log"
8
+ require_relative "../../memory_bundle/file_standing"
8
9
 
9
10
  module Samagotchi
10
11
  module Web
@@ -52,10 +53,10 @@ module Samagotchi
52
53
  AUTO_NOTICES = {
53
54
  "installed" => lambda do |version, notes|
54
55
  ["installed", "chi #{version} is installed",
55
- "chi web and its sessions switch over when you restart them.#{kept_words(notes[:kept])}"]
56
+ "chi web and its sessions switch over when you restart them.#{file_words(notes)}"]
56
57
  end,
57
58
  "updated" => lambda do |version, notes|
58
- ["updated", "chi updated to #{version}", "#{restarted_words(notes[:restarted], notes[:left])}#{kept_words(notes[:kept])}"]
59
+ ["updated", "chi updated to #{version}", "#{restarted_words(notes[:restarted], notes[:left])}#{file_words(notes)}"]
59
60
  end,
60
61
  "restart_failed" => lambda do |version, notes|
61
62
  ["restart_failed", "chi #{version} is installed; chi web didn't restart",
@@ -79,13 +80,10 @@ module Samagotchi
79
80
  "#{words}."
80
81
  end
81
82
 
82
- # " Kept your edits in 2 files." ("" for none).
83
- def self.kept_words(count)
84
- if count.to_i.positive?
85
- " Kept your edits in #{count} #{count == 1 ? "file" : "files"}."
86
- else
87
- ""
88
- end
83
+ # " Merged the update into 1 file you edited. 2 files stay behind." ("" for none).
84
+ def self.file_words(notes)
85
+ words = MemoryBundle::FileStanding.counts_sentence(merged: notes[:merged].to_i, behind: notes[:kept].to_i)
86
+ words.empty? ? "" : " #{words}"
89
87
  end
90
88
 
91
89
  # A show carries +open+ only while its JSON stays this small: one
@@ -216,7 +216,8 @@ module Samagotchi
216
216
  # `chi_update` frames are the page's), and not kept for a snapshot.
217
217
  # @param kind [String] installed, updated or failed
218
218
  # @param version [String] the release it is about
219
- # @param notes [Hash] detail:, kept: (a count), restarted:, left:
219
+ # @param notes [Hash] detail:, kept:, merged: (counts of edited files that
220
+ # stayed behind and that took the update), restarted:, left:
220
221
  def publish_notice(kind:, version:, **notes)
221
222
  @monitor.synchronize { emit("chi_notice", { kind: kind, version: version, **notes }) }
222
223
  nil