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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -1
- data/README.md +1 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +18 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +4 -1
- data/docs/cli.md +29 -24
- data/docs/configuration.md +43 -9
- data/docs/context.md +5 -3
- data/docs/guardrails.md +12 -7
- data/docs/hooks.md +53 -83
- data/docs/internals/bridge.md +84 -7
- data/docs/internals/bundles.md +69 -46
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +23 -7
- data/docs/internals/participants.md +3 -2
- data/docs/internals/remote-sessions.md +5 -2
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +1 -1
- data/docs/memory.md +51 -10
- data/docs/plugins.md +48 -11
- data/docs/releasing.md +6 -5
- data/docs/sessions.md +13 -13
- data/docs/sub-agent.md +17 -2
- data/docs/testing.md +56 -5
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +5 -7
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
- data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
- data/lib/samagotchi/bridge.rb +31 -6
- data/lib/samagotchi/broadcast/recipients.rb +8 -8
- data/lib/samagotchi/broadcast/triage_model.rb +1 -1
- data/lib/samagotchi/bundle_command/check.rb +41 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +20 -45
- data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
- data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
- data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +6 -6
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +8 -22
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_sources.rb +9 -6
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/effective_model.rb +57 -22
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- data/lib/samagotchi/engine.rb +30 -9
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +26 -44
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_registry.rb +57 -45
- data/lib/samagotchi/kernel_loop.rb +7 -3
- data/lib/samagotchi/llm/chat_loop.rb +6 -3
- data/lib/samagotchi/llm_context_strategy.rb +27 -63
- data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
- data/lib/samagotchi/memory_bundle/builder.rb +12 -35
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
- data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
- data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
- data/lib/samagotchi/memory_bundle/installer.rb +212 -178
- data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
- data/lib/samagotchi/memory_bundle/merger.rb +69 -65
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
- data/lib/samagotchi/memory_bundle/profile.rb +5 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
- data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
- data/lib/samagotchi/memory_bundle/status.rb +26 -30
- data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
- data/lib/samagotchi/memory_bundle/trash.rb +28 -18
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_profile.rb +1 -0
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +42 -10
- data/lib/samagotchi/plugin/loader.rb +75 -15
- data/lib/samagotchi/plugin/sessions.rb +1 -1
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +26 -0
- data/lib/samagotchi/reply_wait/liveness.rb +38 -0
- data/lib/samagotchi/reply_wait/poll.rb +44 -15
- data/lib/samagotchi/reply_wait/target.rb +113 -0
- data/lib/samagotchi/reply_wait.rb +22 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +34 -35
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +349 -441
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -6
- data/lib/samagotchi/session_access/remote.rb +5 -3
- data/lib/samagotchi/session_field.rb +134 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/emptiness.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +29 -100
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_metrics.rb +7 -4
- data/lib/samagotchi/session_paths.rb +57 -0
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/session_row.rb +200 -0
- data/lib/samagotchi/session_setup.rb +15 -12
- data/lib/samagotchi/sessions_command.rb +11 -129
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +3 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -4
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/thinking.rb +16 -21
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +18 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/delegate.rb +60 -51
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- data/lib/samagotchi/update_command.rb +11 -7
- data/lib/samagotchi/verify_fresh.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +11 -14
- data/lib/samagotchi/web/app.rb +66 -21
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +1 -1
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/activity.js +16 -7
- data/lib/samagotchi/web/public/app.js +29 -8
- data/lib/samagotchi/web/public/bubbles.js +8 -3
- data/lib/samagotchi/web/public/cards_place.js +15 -0
- data/lib/samagotchi/web/public/commands.js +5 -1
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/history_shape.js +220 -0
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/recap_stamp.js +17 -0
- data/lib/samagotchi/web/public/reload_check.js +71 -0
- data/lib/samagotchi/web/public/stage_model.js +7 -5
- data/lib/samagotchi/web/public/stage_view.js +7 -3
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +43 -2
- data/lib/samagotchi/web/public/turn_block.js +215 -30
- data/lib/samagotchi/web/public/turn_drawing.js +43 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -1
- data/lib/samagotchi/web/public/turn_pieces.js +29 -0
- data/lib/samagotchi/web/reload_check_route.rb +95 -0
- data/lib/samagotchi/web/server.rb +4 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +64 -2
- data/lib/samagotchi/web/session_summary.rb +36 -24
- data/lib/samagotchi/worker.rb +28 -5
- data/lib/samagotchi/worker_link.rb +3 -0
- metadata +22 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
- 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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|