samagotchi 0.134.0 → 0.136.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -7,13 +7,16 @@ import { inTree } from "./sessions_list.js";
7
7
  // and Unarchive, Delete and Stop, from the info bar's buttons, a card's or a
8
8
  // batch; and the start page when the open session is gone.
9
9
  //
10
- // Hooks: +selected()+ the open session's id or null; +state()+ its fields
11
- // (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
10
+ // Hooks: +selected()+ the open session's id or null; +store+ the page's
11
+ // session store (session_store.js: Stop's status; touch("infoBar") for the
12
+ // continue and review in-flight sets, which it doesn't hold); +sessions()+ /
13
+ // +setSessions(list)+ the page's list (the store's: a new list redraws the
14
+ // lists and the bar);
12
15
  // +api+: {continueSession, startReview, archiveSession, unarchiveSession,
13
16
  // deleteSession, stopSession, sessionHolds, stopSessionHolds} (data.js);
14
17
  // +eventsLive()+ the hub's stream is open (else
15
- // +refresh()+ fetches the list); +lists+ (lists_view.js: render);
16
- // +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
18
+ // +refresh()+ fetches the list);
19
+ // +select(id)+, +clearSelection()+, +showNotice(text)+,
17
20
  // +toast(text, action, options)+, +addCreatedSession(created)+,
18
21
  // +fillOpener(created, continuedId)+ (the new link's composer; returns a draft
19
22
  // from another session it displaced, or null), +restoreDraft(draft, opener)+
@@ -28,8 +31,8 @@ export function reviewStartedText(id, created) {
28
31
  }
29
32
 
30
33
  export function createSessionActions({
31
- selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
32
- select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
34
+ selected, store, sessions, setSessions, api, eventsLive, refresh,
35
+ select, clearSelection, showNotice, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
33
36
  alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
34
37
  // The Stop sheet (stop_sheet.js): +open(id, holds)+ shows it and answers
35
38
  // true; nothing to stop (or no sheet wired) is false, and Stop is the
@@ -59,7 +62,6 @@ export function createSessionActions({
59
62
  }
60
63
  setSessions(sessions().filter((s) => s.id !== id));
61
64
  if (selected() === id) clearSelection();
62
- lists.render();
63
65
  }
64
66
 
65
67
  // The open session no longer exists: back to the start page, saying why.
@@ -146,7 +148,7 @@ export function createSessionActions({
146
148
  const id = selected();
147
149
  const short = id.slice(0, 8);
148
150
  continuingIds.add(id);
149
- updateInfoBar();
151
+ store.touch("infoBar");
150
152
  try {
151
153
  const created = await api.continueSession(id);
152
154
  markArchived([id], true);
@@ -176,7 +178,7 @@ export function createSessionActions({
176
178
  }
177
179
  } finally {
178
180
  continuingIds.delete(id);
179
- updateInfoBar();
181
+ store.touch("infoBar");
180
182
  }
181
183
  }
182
184
 
@@ -192,7 +194,7 @@ export function createSessionActions({
192
194
  if (!selected() || reviewingIds.has(selected())) return null;
193
195
  const id = selected();
194
196
  reviewingIds.add(id);
195
- updateInfoBar();
197
+ store.touch("infoBar");
196
198
  try {
197
199
  const created = await api.startReview(id);
198
200
  addCreatedSession(created);
@@ -210,7 +212,7 @@ export function createSessionActions({
210
212
  return null;
211
213
  } finally {
212
214
  reviewingIds.delete(id);
213
- updateInfoBar();
215
+ store.touch("infoBar");
214
216
  }
215
217
  }
216
218
 
@@ -235,7 +237,7 @@ export function createSessionActions({
235
237
  // archive): don't wait for its stream to notice.
236
238
  function stoppedWorker(id) {
237
239
  closeLiveStream();
238
- state().status = "stopped";
240
+ store.setStatus("stopped", "stop");
239
241
  workerGone(id);
240
242
  }
241
243
 
@@ -270,8 +272,7 @@ export function createSessionActions({
270
272
  alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
271
273
  return;
272
274
  }
273
- lists.render();
274
- updateInfoBar();
275
+ // markArchived's list redrew the lists and the bar already.
275
276
  if (!eventsLive()) refresh();
276
277
  const what = archive ? "Archived" : "Unarchived";
277
278
  toast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
@@ -0,0 +1,152 @@
1
+ // The open session's fields (session_state.js emptySessionState), one entry
2
+ // each: where a payload (GET /sessions/:id) and a hub card carry it, what
3
+ // redraws it (session_store.js ORDER names) and who writes it. The table a
4
+ // new field or a new writer starts from (docs/internals/web-frontend.md,
5
+ // "The session store").
6
+ //
7
+ // Every write goes through the store (app.js `store`: update, setStatus,
8
+ // replaceCurrent), never `current.X = …` (the lint rule in
9
+ // eslint.config.js). Two writers stay outside, on purpose (D8):
10
+ // - live_timing.js sets `timing().activeTurn` in place (start, finish,
11
+ // finishLine): the running turn's own clock, which mergeTurnTiming keeps.
12
+ // - verdict_fresh.js `startFresh` pushes onto `fresh.running`, which is
13
+ // `verifyFresh` (verifyFreshState hands the array out): returning the
14
+ // entry for an update would leave the card holding the old array.
15
+ //
16
+ // A new module: the page's modules are cached at unversioned URLs, and a
17
+ // new export of an old module would leave a cached copy without it.
18
+
19
+ import { withLiveStatus } from "./format.js";
20
+
21
+ const isList = (v) => Array.isArray(v);
22
+ const list = (v) => (isList(v) ? v : undefined);
23
+
24
+ // Per field:
25
+ // source(payload) the payload carries it (not undefined): a partial
26
+ // re-read takes it (session_payload.js sessionFieldsFrom).
27
+ // No source: a payload always sets it (sessionStateFrom),
28
+ // or never does (the page's own fields).
29
+ // card(sess) its value on a hub card (followSelectedSession)
30
+ // redraws what reads it (session_store.js ORDER)
31
+ // writers who writes it (besides renderSessionView's payload and
32
+ // forgetSession's reset, which write every field)
33
+ export const FIELDS = {
34
+ firstPreview: { redraws: ["infoBar"], writers: "send_turn.js (the first prompt)" },
35
+ usedMemories: {
36
+ source: (d) => list(d.session?.used_memory_names),
37
+ redraws: ["infoBar"],
38
+ writers: "setUsedMemories (stream), readTurnEnd; both replace the card too",
39
+ },
40
+ preloadedMemories: { source: (d) => list(d.session?.preloaded_memory_names), redraws: ["infoBar"], writers: "the payload" },
41
+ mutedMemories: { source: (d) => list(d.session?.muted_memory_names), redraws: ["infoBar"], writers: "the payload" },
42
+ promptNotes: { source: (d) => list(d.session?.prompt_notes), redraws: ["infoBar"], writers: "readTurnEnd" },
43
+ parentId: { redraws: ["infoBar"], writers: "the payload" },
44
+ continues: { redraws: ["infoBar"], writers: "the payload" },
45
+ review: { redraws: ["infoBar"], writers: "the payload" },
46
+ verdicts: {
47
+ source: (d) => list(d.session?.verdicts),
48
+ redraws: [],
49
+ writers: "placeVerdict (its card draws itself: placeVerdictWithFresh)",
50
+ },
51
+ verifyFresh: {
52
+ source: (d) => list(d.session?.verify_fresh),
53
+ redraws: [],
54
+ writers: "placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
55
+ },
56
+ createdAt: { redraws: ["infoBar"], writers: "the payload" },
57
+ ctxPct: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_chunk, context_status)" },
58
+ ctxWindow: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
59
+ ctxWindowSource: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
60
+ ctxUnknownAfterSwitch: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
61
+ status: {
62
+ source: (d) => d.session?.status || undefined,
63
+ card: (sess) => sess.status,
64
+ redraws: ["infoBar"],
65
+ writers: "store.setStatus only (statusWrite below): the hub, the stream, a turn end's re-read, Stop, the payload",
66
+ },
67
+ thinking: { redraws: ["settingsPanel", "infoBar"], writers: "stream_handlers.js command_ran" },
68
+ model: {
69
+ source: (d) => d.session?.model_name,
70
+ redraws: ["infoBar"],
71
+ writers: "stream_handlers.js command_ran (a new model also syncModelPick: an event, D9)",
72
+ },
73
+ served: { redraws: ["infoBar"], writers: "stream_handlers.js (command_ran, generation_completed)" },
74
+ dir: { source: (d) => d.session?.working_directory, redraws: ["infoBar"], writers: "the payload" },
75
+ owner: {
76
+ card: (sess) => sess.owner || null,
77
+ redraws: ["composer", "infoBar"],
78
+ writers: "followSelectedSession, workerGone (worker → null), send_turn.js and composerCommands.ownedByTui (\"tui\")",
79
+ },
80
+ commands: {
81
+ source: (d) => list(d.commands),
82
+ redraws: ["infoBar"],
83
+ writers: "the payload (the / list and the annotate bar read it at use; the bar refreshes the annotate bar)",
84
+ },
85
+ workerVersion: { card: (sess) => sess.worker_version || null, redraws: ["infoBar"], writers: "followSelectedSession" },
86
+ workerFeatures: {
87
+ card: (sess) => (isList(sess.worker_features) ? sess.worker_features : []),
88
+ redraws: ["featureGates", "infoBar"],
89
+ writers: "followSelectedSession",
90
+ },
91
+ bridgeUp: {
92
+ card: (sess) => sess.bridge_up === true,
93
+ redraws: ["featureGates", "infoBar"],
94
+ writers: "followSelectedSession, workerGone",
95
+ },
96
+ llmContext: {
97
+ redraws: ["settingsPanel", "workingSetStrategy", "infoBar"],
98
+ writers: "stream_handlers.js command_ran",
99
+ },
100
+ reasoningHistory: { redraws: ["settingsPanel", "infoBar"], writers: "stream_handlers.js command_ran" },
101
+ toolkit: { redraws: ["infoBar"], writers: "stream_handlers.js (command_ran, hook_notice)" },
102
+ toolkitDefault: { redraws: ["infoBar"], writers: "the payload" },
103
+ timing: {
104
+ source: (d) => d.timing,
105
+ redraws: ["infoBar"],
106
+ writers: "mergeTurnTiming; stream_handlers.js (generation_completed: tokens, modelSegments; side_usage: side), " +
107
+ "always a new object; live_timing.js sets activeTurn in place (D8)",
108
+ },
109
+ turnStarted: { redraws: ["composer", "infoBar"], writers: "setTurnRunning" },
110
+ streamSeq: { redraws: [], writers: "resync" },
111
+ streamFromGuess: { redraws: [], writers: "followWokenWorker, startStream, the stream's takeGuess" },
112
+ };
113
+
114
+ // The fields a hub card brings for the open session (followSelectedSession):
115
+ // its owner and worker. Never its status: that goes through setStatus, so
116
+ // "the hub's status only while not streaming" can't be bypassed.
117
+ const CARD_FIELDS = ["owner", "workerVersion", "workerFeatures", "bridgeUp"];
118
+
119
+ // @param sess a hub card (the open session's)
120
+ // @return {owner, workerVersion, workerFeatures, bridgeUp}
121
+ export function fieldsFromCard(sess) {
122
+ return Object.fromEntries(CARD_FIELDS.map((key) => [key, FIELDS[key].card(sess)]));
123
+ }
124
+
125
+ // Whether a status write from +source+ applies, and what it does to the
126
+ // open session's card (null: the card is left alone).
127
+ // payload renderSessionView: always
128
+ // hub followSelectedSession: only while the stream isn't live
129
+ // stream setLiveStatus (turn_*, workerGone's idle): always; the card
130
+ // takes it too (withLiveStatus: status, owner, updated_at)
131
+ // turnEnd readTurnEnd, the turn_canceled re-read: only while no turn
132
+ // runs (a re-read landing after the next queued turn's
133
+ // turn_started doesn't write its stored "idle" over "running";
134
+ // mergeTurnTiming keeps activeTurn by the same test); the caller
135
+ // skips a payload without one; readTurnEnd writes its card
136
+ // itself, after its timing merge
137
+ // stop session_actions.js stoppedWorker: always; workerGone's
138
+ // withoutWorker takes care of the card
139
+ // @param source one of the above
140
+ // @param streaming whether the open session's stream is live
141
+ // @param running whether a turn runs on the page (liveTurn.running)
142
+ // @return {apply, card: (sess, status) => sess | null}
143
+ export function statusWrite(source, { streaming = false, running = false } = {}) {
144
+ switch (source) {
145
+ case "payload": return { apply: true, card: null };
146
+ case "hub": return { apply: !streaming, card: null };
147
+ case "stream": return { apply: true, card: withLiveStatus };
148
+ case "turnEnd": return { apply: !running, card: null };
149
+ case "stop": return { apply: true, card: null };
150
+ default: throw new Error(`statusWrite: no source ${source}`);
151
+ }
152
+ }
@@ -0,0 +1,35 @@
1
+ // A partial re-read of the open session (GET /sessions/:id with ?tail,
2
+ // ?recent…): the fields of the page's session state it brings, read as a
3
+ // full load reads them (session_state.js sessionStateFrom), so a field's
4
+ // rule lives in one place. app.js applySessionPayload writes them through
5
+ // the store; the caller then calls the field's redraw
6
+ // (docs/internals/web-frontend.md, "The session store").
7
+ //
8
+ // A new module, not an export of session_state.js: the page's modules are
9
+ // cached at unversioned URLs, and a new export of an old module would
10
+ // leave a cached copy without it.
11
+
12
+ import { sessionStateFrom } from "./session_state.js";
13
+ import { FIELDS } from "./session_fields.js";
14
+
15
+ // Where each field comes from in the payload (session_fields.js FIELDS
16
+ // sources): a field whose source the payload doesn't carry (a partial read
17
+ // leaves it out, or an older chi web never sent it) is not touched. Fields
18
+ // without one are always taken.
19
+ //
20
+ // The named +fields+ of the session state +data+ sets (sessionStateFrom's
21
+ // values), those whose source it carries.
22
+ // @param data a GET /sessions/:id answer (it has a session)
23
+ // @param fields the state's keys (emptySessionState's), e.g. ["status"]
24
+ // @return {field: value} for the fields it brings
25
+ export function sessionFieldsFrom(data, fields) {
26
+ if (!data?.session) return {};
27
+ const all = sessionStateFrom(data);
28
+ const out = {};
29
+ for (const field of fields) {
30
+ const source = FIELDS[field]?.source;
31
+ if (source && source(data) === undefined) continue;
32
+ if (field in all) out[field] = all[field];
33
+ }
34
+ return out;
35
+ }
@@ -40,6 +40,8 @@ export function emptySessionState() {
40
40
  // (stream_handlers.js context_status): the ctx tooltip says it waits.
41
41
  ctxUnknownAfterSwitch: false,
42
42
  status: "",
43
+ // The level the next turn thinks at ({level, source, own}: the thinking chip), or null.
44
+ thinking: null,
43
45
  model: "",
44
46
  // What the worker's server said it served for a model name: [served, asked].
45
47
  served: null,
@@ -55,12 +57,15 @@ export function emptySessionState() {
55
57
  workerFeatures: [],
56
58
  bridgeUp: false,
57
59
  // The LLM context strategy, apply rule and budget the next turn runs
58
- // under, each with its source (the info bar's llm ctx chip), or null.
60
+ // under, each with its source (the info bar's ⚙ chip), or null.
59
61
  llmContext: null,
60
62
  // Which of its reasoning the model gets back from the next turn on, with
61
- // its source (the reasoning history chip), or null (a native host).
63
+ // its source (the ⚙ chip's), or null (a native host).
62
64
  reasoningHistory: null,
63
65
  toolkit: null,
66
+ // config's toolkit.default as chi web reads it (the 🧰 chip: a session on
67
+ // it shows 🧰 alone), or null (an older chi web: no 🧰 for full).
68
+ toolkitDefault: null,
64
69
  timing: normalizeTiming(),
65
70
  // A turn started on it while the page had it open (setTurnRunning): a
66
71
  // fresh link's picker goes at once, before the timing re-read brings the
@@ -74,8 +79,6 @@ export function emptySessionState() {
74
79
  // The stream was opened from 0 (a wake with no cursor to go on) and no
75
80
  // new turn has started on it yet (app.js followWokenWorker).
76
81
  streamFromGuess: false,
77
- // When the info bar's "copy chi --attach" copied (its "copied ✓").
78
- attachCopiedAt: 0,
79
82
  };
80
83
  }
81
84
 
@@ -116,8 +119,9 @@ export function sessionStateFrom(data) {
116
119
  state.thinking = s.thinking || null;
117
120
  // null on a native host (it ignores the level) and from an older worker.
118
121
  state.reasoningHistory = s.reasoning_history || null;
119
- // The tools chip's (null on full).
122
+ // The 🧰 chip's (null on full) and the default kit it is read against.
120
123
  state.toolkit = s.toolkit || null;
124
+ state.toolkitDefault = s.toolkit_default || null;
121
125
  state.parentId = s.parent_id || null;
122
126
  state.continues = s.continues || null;
123
127
  state.review = s.review || null;
@@ -0,0 +1,191 @@
1
+ // The owner page's session state in one place: the open session's id
2
+ // (selected), its fields (current, session_state.js) and every card
3
+ // (sessions, the hub's projection). Writes go through update() (and
4
+ // replaceCurrent() for forgetSession's reset); a write marks the redraws
5
+ // that read what changed, and they run synchronously at the end of the
6
+ // outermost batch(), or at once for a bare write.
7
+ // See docs/internals/web-frontend.md ("The session store").
8
+ //
9
+ // A new module: the page's modules are cached at unversioned URLs, and a
10
+ // new export of an old module would leave a cached copy without it.
11
+
12
+ import { FIELDS, statusWrite } from "./session_fields.js";
13
+
14
+ // The redraws, by name, in the one order a flush runs them: the lists
15
+ // before the bar (updateSessionTitle reads the strip's active card), the
16
+ // composer before the bar (as setTurnRunning runs them).
17
+ export const ORDER = [
18
+ "lists",
19
+ "paintActive",
20
+ "settingsPanel", "workingSetStrategy",
21
+ "featureGates",
22
+ "composer",
23
+ "infoBar",
24
+ ];
25
+
26
+ // What a change of the list or of the open session's id redraws: the bar
27
+ // reads the list too (the chain's ← → links, the children chip, the
28
+ // archived button, the card's title, the fresh link's picker).
29
+ const SESSIONS_REDRAWS = ["lists", "infoBar"];
30
+ const SELECTED_REDRAWS = ["paintActive", "composer", "infoBar"];
31
+
32
+ // A flush whose redraws keep writing stops after this many passes.
33
+ export const MAX_PASSES = 3;
34
+
35
+ // @param initial the open session's fields to start from (emptySessionState())
36
+ // @param fields {key: {redraws: [name]}} what each field of `current` redraws
37
+ // (session_fields.js FIELDS by default); a field it doesn't list redraws nothing
38
+ // @param log where a refused batch, a capped flush and a redraw error that
39
+ // can't be thrown go (console by default)
40
+ export function createSessionStore({ initial = {}, fields = FIELDS, log = console } = {}) {
41
+ let selected = null;
42
+ let current = initial;
43
+ let sessions = [];
44
+ const redraws = new Map(ORDER.map((name) => [name, []]));
45
+ const pending = new Set();
46
+ let depth = 0;
47
+ let flushing = false;
48
+
49
+ const enqueue = (names) => names.forEach((name) => pending.add(name));
50
+ const fieldRedraws = (key) => fields[key]?.redraws || [];
51
+
52
+ // Run the pending redraws in ORDER, a pass at a time: a write made during
53
+ // a redraw only enqueues (the flushing flag), and the next pass takes it.
54
+ // A redraw that throws doesn't stop the others; the first error is thrown
55
+ // once the flush is over.
56
+ function flush() {
57
+ if (flushing || depth > 0) return;
58
+ flushing = true;
59
+ let error = null;
60
+ try {
61
+ for (let pass = 0; pending.size > 0; pass += 1) {
62
+ if (pass === MAX_PASSES) {
63
+ log.error(`session store: still redrawing after ${MAX_PASSES} passes: ${[...pending].join(", ")}`);
64
+ pending.clear();
65
+ break;
66
+ }
67
+ const names = ORDER.filter((name) => pending.has(name));
68
+ pending.clear();
69
+ for (const name of names) {
70
+ for (const fn of redraws.get(name)) {
71
+ try {
72
+ fn();
73
+ } catch (e) {
74
+ error ??= e;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ } finally {
80
+ flushing = false;
81
+ }
82
+ if (error) throw error;
83
+ }
84
+
85
+ const store = {
86
+ get selected() { return selected; },
87
+ get current() { return current; },
88
+ get sessions() { return sessions; },
89
+
90
+ // Write the given parts: +selected+ (an id or null), +current+ (fields
91
+ // written into the open session's object, which stays the same object),
92
+ // +sessions+ (a new list: one changed in place isn't seen). A changed
93
+ // value (!==) marks its redraws.
94
+ update(patch = {}) {
95
+ if ("selected" in patch && patch.selected !== selected) {
96
+ selected = patch.selected;
97
+ enqueue(SELECTED_REDRAWS);
98
+ }
99
+ if (patch.current) {
100
+ for (const [key, value] of Object.entries(patch.current)) {
101
+ if (current[key] === value) continue;
102
+ current[key] = value;
103
+ enqueue(fieldRedraws(key));
104
+ }
105
+ }
106
+ if ("sessions" in patch && patch.sessions !== sessions) {
107
+ sessions = patch.sessions;
108
+ enqueue(SESSIONS_REDRAWS);
109
+ }
110
+ flush();
111
+ },
112
+
113
+ // The open session's status from +source+ (session_fields.js statusWrite:
114
+ // payload, hub, stream, turnEnd, stop), under that source's rule; a
115
+ // source with a card rule replaces the open session's card too.
116
+ // @param streaming whether the open session's stream is live (the hub's rule)
117
+ // @param running whether a turn runs on the page
118
+ // @return whether the open session's card was replaced (the caller redraws the lists)
119
+ setStatus(value, source, { streaming = false, running = false } = {}) {
120
+ const { apply, card } = statusWrite(source, { streaming, running });
121
+ if (!apply) return false;
122
+ let carded = false;
123
+ store.batch(() => {
124
+ store.update({ current: { status: value } });
125
+ if (!card) return;
126
+ const i = sessions.findIndex((s) => s.id === selected);
127
+ if (i < 0) return;
128
+ const next = sessions.slice();
129
+ next[i] = card(sessions[i], value);
130
+ store.update({ sessions: next });
131
+ carded = true;
132
+ });
133
+ return carded;
134
+ },
135
+
136
+ // A new open session's fields (forgetSession only): always a new object,
137
+ // so a stale async writer still holding the old one writes into it alone.
138
+ replaceCurrent(state) {
139
+ current = { ...state };
140
+ enqueue(Object.keys(current).flatMap(fieldRedraws));
141
+ flush();
142
+ },
143
+
144
+ // Run +fn+ (synchronous) with its writes' redraws held to its end; nested
145
+ // batches join the outermost. The redraws run even when +fn+ throws (the
146
+ // page shows what was written), then its error is rethrown. An +fn+ that
147
+ // returns a promise is refused: its writes after an await would land
148
+ // outside the batch, so batch only an async function's synchronous parts.
149
+ batch(fn) {
150
+ depth += 1;
151
+ let result;
152
+ let failed = false;
153
+ let error;
154
+ try {
155
+ result = fn();
156
+ } catch (e) {
157
+ failed = true;
158
+ error = e;
159
+ } finally {
160
+ depth -= 1;
161
+ }
162
+ if (result && typeof result.then === "function") {
163
+ log.error("session store: batch() takes a synchronous function; batch an async one's synchronous parts");
164
+ }
165
+ if (failed) {
166
+ try {
167
+ flush();
168
+ } catch (e) {
169
+ log.error(e);
170
+ }
171
+ throw error;
172
+ }
173
+ flush();
174
+ return result;
175
+ },
176
+
177
+ // Something the store doesn't hold changed (the clock, versions, the
178
+ // stream): +name+'s redraw runs.
179
+ touch(name) {
180
+ enqueue([name]);
181
+ flush();
182
+ },
183
+
184
+ // Register +fn+ as (one of) the redraw(s) called +name+ (one of ORDER).
185
+ on(name, fn) {
186
+ if (!redraws.has(name)) throw new Error(`session store: no redraw called ${name}`);
187
+ redraws.get(name).push(fn);
188
+ },
189
+ };
190
+ return store;
191
+ }