samagotchi 0.55.0 → 0.57.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 +65 -1
- data/README.md +1 -1
- data/docs/architecture.md +6 -2
- data/docs/cli.md +30 -8
- data/docs/configuration.md +84 -8
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +14 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +7 -3
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +19 -3
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +29 -6
- data/lib/samagotchi/web/public/app.js +44 -9
- data/lib/samagotchi/web/public/commands.js +36 -3
- data/lib/samagotchi/web/public/composer.js +5 -1
- data/lib/samagotchi/web/public/index.html +22 -5
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +10 -2
- data/lib/samagotchi/web/public/thinking_chip.js +33 -138
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +17 -5
- metadata +13 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// `path:line` refs in an answer's or a step's text (traceable refs P3).
|
|
2
|
+
// A name becomes a file ref (refs.js: span.ref with data-ref-kind="file")
|
|
3
|
+
// only when it names a file the session's rows touched (their refs): the
|
|
4
|
+
// answer's text is unchanged, it resolves on the page, and nothing the
|
|
5
|
+
// session didn't touch gets linked. The scan is github-pr's LineLinker
|
|
6
|
+
// (bundles/github-pr/plugin.rb) on the DOM: pre, links and fenced blocks
|
|
7
|
+
// are left alone, inline code only when it is the ref alone, a bare URL's
|
|
8
|
+
// text too.
|
|
9
|
+
|
|
10
|
+
import { refAttrs } from "./refs.js";
|
|
11
|
+
|
|
12
|
+
// A path (no spaces), a line, an optional end line and an optional column
|
|
13
|
+
// (kept in the text, not used). LineLinker::REF.
|
|
14
|
+
export const REF = /(?<![\w./@+~-])(?<path>[\w./@+~-]*[\w@+~-]):(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])/;
|
|
15
|
+
export const REF_ALONE = new RegExp(`^${REF.source}$`);
|
|
16
|
+
// A ref, or a bare path (no `:line`): a bare one counts only when it is a
|
|
17
|
+
// touched file exactly (resolveRef's +bare+). Not after a `\` or a `:`
|
|
18
|
+
// either: the tail of `C:\x\foo.rb:3` or `host:lib/foo.rb:3` is another
|
|
19
|
+
// machine's file.
|
|
20
|
+
const REF_OR_PATH = /(?<![\w./@+~\\:-])(?<path>[\w./@+~-]*[\w@+~-])(?::(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])|(?![\w/]|:\d))/g;
|
|
21
|
+
const PATH_ALONE = /^(?<path>[\w./@+~-]*[\w@+~-])$/;
|
|
22
|
+
const URL_SPAN = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
|
23
|
+
const MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/g;
|
|
24
|
+
// A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
25
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
26
|
+
// Elements whose text is never scanned: code blocks, links (a github-pr
|
|
27
|
+
// PR link stays the PR's), a ref already made.
|
|
28
|
+
const SKIP = "pre, a, [data-ref-kind]";
|
|
29
|
+
|
|
30
|
+
// The ref a match names: { path, line, endLine } or null. A path has a `/`
|
|
31
|
+
// or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
|
|
32
|
+
// a ref; an end line not after the line is dropped.
|
|
33
|
+
export function refOf(groups) {
|
|
34
|
+
const path = groups?.path || "";
|
|
35
|
+
if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
|
|
36
|
+
if (groups.from === undefined) return { path, line: null, endLine: null };
|
|
37
|
+
const line = Number(groups.from);
|
|
38
|
+
if (!line) return null;
|
|
39
|
+
const to = groups.to === undefined ? null : Number(groups.to);
|
|
40
|
+
return { path, line, endLine: to && to > line ? to : null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The absolute touched file +path+ names, or null: the path itself when
|
|
44
|
+
// absolute, else the path under +cwd+ (`.` and `..` folded either way); else (not for a +bare+ path: no
|
|
45
|
+
// `:line`) the ONE touched file ending in `/<path>`. Ambiguous: none.
|
|
46
|
+
export function resolveRef(path, touched, cwd, { bare = false } = {}) {
|
|
47
|
+
const rel = String(path).replace(/^(?:\.\/)+/, "");
|
|
48
|
+
const exact = rel.startsWith("/") ? joinPath("", rel) : cwd ? joinPath(cwd, rel) : null;
|
|
49
|
+
if (exact && touched.has(exact)) return exact;
|
|
50
|
+
if (bare || rel.startsWith("/")) return null;
|
|
51
|
+
const tail = `/${rel.replace(/^(?:\.\.\/)+/, "")}`;
|
|
52
|
+
const found = [...touched].filter((file) => file.endsWith(tail));
|
|
53
|
+
return found.length === 1 ? found[0] : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// +rel+ under +dir+, `.` and `..` segments folded.
|
|
57
|
+
function joinPath(dir, rel) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const part of `${dir}/${rel}`.split("/")) {
|
|
60
|
+
if (part === "" || part === ".") continue;
|
|
61
|
+
if (part === "..") out.pop();
|
|
62
|
+
else out.push(part);
|
|
63
|
+
}
|
|
64
|
+
return `/${out.join("/")}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// [start, end, ref] for each ref in a plain +text+ that names a touched
|
|
68
|
+
// file: fenced blocks, markdown links and URLs skipped, inline code only
|
|
69
|
+
// as the ref alone (the span with its backticks). +touched+: a Set of
|
|
70
|
+
// absolute paths; +cwd+: the session's working directory.
|
|
71
|
+
export function textRefs(text, touched, cwd) {
|
|
72
|
+
if (!touched?.size || !text) return [];
|
|
73
|
+
const fences = fencedBlocks(text);
|
|
74
|
+
const skipped = [...fences, ...spans(text, URL_SPAN), ...spans(text, MARKDOWN_LINK)];
|
|
75
|
+
const code = inlineCode(text, fences);
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const [start, end, inner] of code) {
|
|
78
|
+
if (insideAny(skipped, start, end)) continue;
|
|
79
|
+
const ref = aloneRef(inner, touched, cwd);
|
|
80
|
+
if (ref) found.push([start, end, ref]);
|
|
81
|
+
}
|
|
82
|
+
for (const match of text.matchAll(REF_OR_PATH)) {
|
|
83
|
+
const start = match.index;
|
|
84
|
+
const end = start + match[0].length;
|
|
85
|
+
if (insideAny(skipped, start, end) || insideAny(code, start, end)) continue;
|
|
86
|
+
const ref = resolved(refOf(match.groups), touched, cwd);
|
|
87
|
+
if (ref) found.push([start, end, ref]);
|
|
88
|
+
}
|
|
89
|
+
return found.sort((a, b) => a[0] - b[0]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The ref an inline code's text is when it is a ref (or a touched path)
|
|
93
|
+
// alone, else null.
|
|
94
|
+
function aloneRef(text, touched, cwd) {
|
|
95
|
+
const inner = String(text).trim();
|
|
96
|
+
const match = inner.match(REF_ALONE) || inner.match(PATH_ALONE);
|
|
97
|
+
return match ? resolved(refOf(match.groups), touched, cwd) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolved(ref, touched, cwd) {
|
|
101
|
+
if (!ref) return null;
|
|
102
|
+
const path = resolveRef(ref.path, touched, cwd, { bare: ref.line === null });
|
|
103
|
+
return path ? { kind: "file", path, line: ref.line, end_line: ref.endLine } : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The absolute paths of the file refs under +roots+ (the session's rows,
|
|
107
|
+
// approval cards): what a text ref may name.
|
|
108
|
+
export function touchedFiles(roots) {
|
|
109
|
+
const touched = new Set();
|
|
110
|
+
for (const root of roots) {
|
|
111
|
+
for (const el of root.querySelectorAll('[data-ref-kind="file"][data-ref-path]')) {
|
|
112
|
+
touched.add(el.dataset.refPath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return touched;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Wraps the refs in +el+'s text in span.ref (refs.js clicks and
|
|
119
|
+
// hover them as it does a row title). Text inside pre, a link or a ref is
|
|
120
|
+
// left alone; an inline code element is wrapped whole when it is a ref
|
|
121
|
+
// alone. Runs again over the same element without wrapping twice.
|
|
122
|
+
export function linkRefs(el, touched, cwd) {
|
|
123
|
+
if (!el || !touched?.size) return;
|
|
124
|
+
const doc = el.ownerDocument;
|
|
125
|
+
for (const code of [...el.querySelectorAll("code")]) {
|
|
126
|
+
if (code.closest(SKIP)) continue;
|
|
127
|
+
const ref = aloneRef(code.textContent, touched, cwd);
|
|
128
|
+
if (!ref) continue;
|
|
129
|
+
const span = refSpan(doc, ref);
|
|
130
|
+
code.replaceWith(span);
|
|
131
|
+
span.append(code);
|
|
132
|
+
}
|
|
133
|
+
const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
|
|
134
|
+
const nodes = [];
|
|
135
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
136
|
+
const skip = node.parentElement?.closest(`${SKIP}, code`);
|
|
137
|
+
if (!skip || !el.contains(skip)) nodes.push(node);
|
|
138
|
+
}
|
|
139
|
+
for (const node of nodes) wrapRefs(node, textRefs(node.data, touched, cwd));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function wrapRefs(node, hits) {
|
|
143
|
+
if (!hits.length) return;
|
|
144
|
+
const doc = node.ownerDocument;
|
|
145
|
+
const frag = doc.createDocumentFragment();
|
|
146
|
+
let pos = 0;
|
|
147
|
+
for (const [start, end, ref] of hits) {
|
|
148
|
+
if (start < pos) continue;
|
|
149
|
+
if (start > pos) frag.append(node.data.slice(pos, start));
|
|
150
|
+
const span = refSpan(doc, ref);
|
|
151
|
+
span.textContent = node.data.slice(start, end);
|
|
152
|
+
frag.append(span);
|
|
153
|
+
pos = end;
|
|
154
|
+
}
|
|
155
|
+
if (pos < node.data.length) frag.append(node.data.slice(pos));
|
|
156
|
+
node.replaceWith(frag);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function refSpan(doc, ref) {
|
|
160
|
+
const holder = doc.createElement("div");
|
|
161
|
+
holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
|
|
162
|
+
return holder.firstChild;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// [start, end] of each fenced block (an unclosed one runs to the end).
|
|
166
|
+
function fencedBlocks(text) {
|
|
167
|
+
const blocks = [];
|
|
168
|
+
let open = null;
|
|
169
|
+
let offset = 0;
|
|
170
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
171
|
+
const marker = line.match(FENCE_OPEN)?.[1];
|
|
172
|
+
if (!open && marker) {
|
|
173
|
+
open = [offset, marker];
|
|
174
|
+
} else if (open && marker && marker[0] === open[1][0] && marker.length >= open[1].length && line.trim() === marker) {
|
|
175
|
+
blocks.push([open[0], offset + line.length]);
|
|
176
|
+
open = null;
|
|
177
|
+
}
|
|
178
|
+
offset += line.length;
|
|
179
|
+
}
|
|
180
|
+
if (open) blocks.push([open[0], text.length]);
|
|
181
|
+
return blocks;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// [start, end, inner] of each inline code span outside +fences+: a run of
|
|
185
|
+
// backticks to the next run of the same length.
|
|
186
|
+
function inlineCode(text, fences) {
|
|
187
|
+
const runs = spans(text, /`+/g).filter(([start, end]) => !insideAny(fences, start, end));
|
|
188
|
+
const found = [];
|
|
189
|
+
let index = 0;
|
|
190
|
+
while (index < runs.length) {
|
|
191
|
+
const [start, end] = runs[index];
|
|
192
|
+
const closing = runs.findIndex(([s, e], k) => k > index && e - s === end - start);
|
|
193
|
+
if (closing < 0) { index += 1; continue; }
|
|
194
|
+
found.push([start, runs[closing][1], text.slice(end, runs[closing][0])]);
|
|
195
|
+
index = closing + 1;
|
|
196
|
+
}
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function spans(text, regex) {
|
|
201
|
+
return [...text.matchAll(regex)].map((m) => [m.index, m.index + m[0].length]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function insideAny(list, start, end) {
|
|
205
|
+
return list.some(([s, e]) => start >= s && end <= e);
|
|
206
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Refs: quiet links from what a turn touched to the thing itself. A ref is
|
|
2
2
|
// a kind and a target: today a file tool's row title (the server's FileRef,
|
|
3
|
-
// {kind: "file", path, line?, end_line?})
|
|
4
|
-
// same file at a line)
|
|
3
|
+
// {kind: "file", path, line?, end_line?}), that row's diff lines and a
|
|
4
|
+
// read row's numbered lines (the same file at a line), and an edit/write
|
|
5
|
+
// approval card's paths and its dry run's diff lines. The DOM carries data-ref-kind and the target's
|
|
5
6
|
// attributes, never what a click does: that is an action, kept per kind in
|
|
6
7
|
// REF_ACTIONS, and a click runs the first one available to this viewer. A
|
|
7
8
|
// ref with none (a phone: no editor) stays plain text; the page marks the
|
|
@@ -51,19 +52,27 @@ export function availableKinds(viewer, actions = REF_ACTIONS) {
|
|
|
51
52
|
return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// What owns a ref and its diff lines: a tool row, or an edit's approval
|
|
56
|
+
// card (its paths and its dry run).
|
|
57
|
+
const OWNERS = ".activity-row, .bubble.approval";
|
|
58
|
+
|
|
59
|
+
// A line of the owner's file: a diff line, or a read row's numbered line.
|
|
60
|
+
const LINES = ".diff-line[data-line], .read-line[data-line]";
|
|
61
|
+
|
|
54
62
|
// What a click on +el+ points at: {kind, path, line, owner} or null. A row
|
|
55
|
-
// title opens its file at the lines the call
|
|
56
|
-
// its first changed line); a diff line (or a
|
|
57
|
-
//
|
|
58
|
-
//
|
|
63
|
+
// title (or an approval card's path) opens its file at the lines the call
|
|
64
|
+
// named (an edit without any at its first changed line); a diff line (or a
|
|
65
|
+
// hunk header) or a read row's numbered line, in a row or a card with a
|
|
66
|
+
// ref, opens that file at that line.
|
|
67
|
+
// A diff line elsewhere is none.
|
|
59
68
|
export function refTarget(el) {
|
|
60
69
|
const title = el?.closest?.("[data-ref-kind]");
|
|
61
70
|
if (title) {
|
|
62
|
-
const owner = title.closest(
|
|
71
|
+
const owner = title.closest(OWNERS);
|
|
63
72
|
return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
|
|
64
73
|
}
|
|
65
|
-
const diffLine = el?.closest?.(
|
|
66
|
-
const owner = diffLine?.closest(
|
|
74
|
+
const diffLine = el?.closest?.(LINES);
|
|
75
|
+
const owner = diffLine?.closest(OWNERS);
|
|
67
76
|
const ref = owner?.querySelector("[data-ref-kind]");
|
|
68
77
|
if (!ref) return null;
|
|
69
78
|
return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
|
|
@@ -116,7 +125,7 @@ export function installRefClicks(root, viewer, { open = defaultOpen, selection =
|
|
|
116
125
|
root.addEventListener("mouseover", (e) => {
|
|
117
126
|
const target = refTarget(e.target);
|
|
118
127
|
if (!target) return;
|
|
119
|
-
const el = e.target.closest("[data-ref-kind]") || e.target.closest(
|
|
128
|
+
const el = e.target.closest("[data-ref-kind]") || e.target.closest(LINES);
|
|
120
129
|
if (!el) return;
|
|
121
130
|
if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
|
|
122
131
|
const own = el.dataset.refTitle;
|
|
@@ -45,6 +45,9 @@ export function emptySessionState() {
|
|
|
45
45
|
// The LLM context strategy, apply rule and budget the next turn runs
|
|
46
46
|
// under, each with its source (the info bar's llm ctx chip), or null.
|
|
47
47
|
llmContext: null,
|
|
48
|
+
// Which of its reasoning the model gets back from the next turn on, with
|
|
49
|
+
// its source (the reasoning history chip), or null (a native host).
|
|
50
|
+
reasoningHistory: null,
|
|
48
51
|
timing: normalizeTiming(),
|
|
49
52
|
};
|
|
50
53
|
}
|
|
@@ -78,6 +81,8 @@ export function sessionStateFrom(data) {
|
|
|
78
81
|
state.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
|
|
79
82
|
state.llmContext = s.llm_context || null;
|
|
80
83
|
state.thinking = s.thinking || null;
|
|
84
|
+
// null on a native host (it ignores the level) and from an older worker.
|
|
85
|
+
state.reasoningHistory = s.reasoning_history || null;
|
|
81
86
|
state.parentId = s.parent_id || null;
|
|
82
87
|
state.continues = s.continues || null;
|
|
83
88
|
state.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// capabilities() the page's capabilities (card.js shownElsewhere)
|
|
16
16
|
// historyEl the history element (keepFollowing, the failed-turn redraw)
|
|
17
17
|
// liveTurn, turnDom, shownTexts, questionCards, contextChips,
|
|
18
|
-
// llmContextControl, thinkingControl
|
|
18
|
+
// llmContextControl, thinkingControl,
|
|
19
|
+
// reasoningHistoryControl the page's views
|
|
19
20
|
// sentIds, earlyRestores restore.js's bookkeeping
|
|
20
21
|
// stream: {takeGuess(), restart()} the stream was opened from a
|
|
21
22
|
// guess (takeGuess: whether, and forget it); restart:
|
|
@@ -49,7 +50,7 @@ import { keepEarlyRestore, restoreAction } from "./restore.js";
|
|
|
49
50
|
export function createStreamHandlers(deps) {
|
|
50
51
|
const {
|
|
51
52
|
selected, state, clientId, capabilities, historyEl,
|
|
52
|
-
liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl,
|
|
53
|
+
liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl, reasoningHistoryControl,
|
|
53
54
|
sentIds, earlyRestores, stream, composer, getSession,
|
|
54
55
|
resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
|
|
55
56
|
appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
|
|
@@ -213,6 +214,13 @@ export function createStreamHandlers(deps) {
|
|
|
213
214
|
updateInfoBar();
|
|
214
215
|
thinkingControl.refresh();
|
|
215
216
|
}
|
|
217
|
+
// /reasoning-history (or /model) changed it; null: the model now runs
|
|
218
|
+
// on a native host, which ignores it (the chip goes).
|
|
219
|
+
if ("reasoning_history" in data) {
|
|
220
|
+
state().reasoningHistory = data.reasoning_history || null;
|
|
221
|
+
updateInfoBar();
|
|
222
|
+
reasoningHistoryControl.refresh();
|
|
223
|
+
}
|
|
216
224
|
if (view.resync) await resync();
|
|
217
225
|
// A card's action that isn't anytime (the step-limit question's
|
|
218
226
|
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
@@ -2,20 +2,35 @@
|
|
|
2
2
|
// low"; "· session" when the session set its own), and a popover with a
|
|
3
3
|
// level select that runs /thinking in the session's worker (the command's
|
|
4
4
|
// reply shows in the conversation, like a typed one). The level and where
|
|
5
|
-
// it came from are the session's thinking (
|
|
5
|
+
// it came from are the session's thinking (SettingExplained#summary:
|
|
6
6
|
// {level, source, own}; the worker's snapshot, a command_ran, or the
|
|
7
|
-
// server's own reading of the session file).
|
|
7
|
+
// server's own reading of the session file). A config on level_chip.js.
|
|
8
8
|
//
|
|
9
9
|
// The start page has one too (kind "new chat"): it shows the picked
|
|
10
10
|
// model's level (GET /api/models' thinking) and keeps a choice for the
|
|
11
11
|
// next create only (POST /api/sessions' thinking), never remembered:
|
|
12
12
|
// nothing is sent unless the user sets one.
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
14
|
+
import { SESSION } from "./llm_context_chip.js";
|
|
15
|
+
import * as level from "./level_chip.js";
|
|
16
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
16
17
|
|
|
17
18
|
export const LEVELS = ["off", "low", "medium", "high"];
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
export const CONFIG = Object.freeze({
|
|
21
|
+
name: "Thinking",
|
|
22
|
+
prefix: "think",
|
|
23
|
+
command: "/thinking",
|
|
24
|
+
levels: LEVELS,
|
|
25
|
+
anchor: "[data-think-chip]",
|
|
26
|
+
formClass: "think-form",
|
|
27
|
+
popoverClass: "think-popover",
|
|
28
|
+
nowClass: "think-now",
|
|
29
|
+
when: (fresh) => (fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
30
|
+
: "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again."),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const { nowText } = level;
|
|
19
34
|
|
|
20
35
|
// The new chat's choice (a level, or null) laid over the model's summary:
|
|
21
36
|
// what the chip shows. The model's own summary without a choice.
|
|
@@ -28,13 +43,8 @@ export function withChoice(summary, choice) {
|
|
|
28
43
|
// set its own; null without a summary (an older worker). The default with
|
|
29
44
|
// nothing set anywhere shows too ("think default"): the chip is where the
|
|
30
45
|
// level is set.
|
|
31
|
-
export function chipModel(summary,
|
|
32
|
-
|
|
33
|
-
const own = !!summary.own;
|
|
34
|
-
const what = kind === NEW_CHAT ? "the new chat" : "this session";
|
|
35
|
-
const from = own ? what : summary.source || "the model's own";
|
|
36
|
-
const title = `Thinking: ${summary.level} (${from}). Click to change it for ${what}.`;
|
|
37
|
-
return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
|
|
46
|
+
export function chipModel(summary, opts) {
|
|
47
|
+
return level.chipModel(CONFIG, summary, opts);
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
// The session bar's chip (a lit-html template; info_bar.js), nothing
|
|
@@ -45,134 +55,19 @@ export function chipTemplate(summary) {
|
|
|
45
55
|
return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
// The /thinking line for a select value
|
|
49
|
-
//
|
|
50
|
-
export function commandLine(
|
|
51
|
-
|
|
52
|
-
return `/thinking ${level}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// "Now: low (session)", "Now: default (the model's own)".
|
|
56
|
-
export function nowText(summary) {
|
|
57
|
-
return `Now: ${summary.level} (${summary.source || "the model's own"})`;
|
|
58
|
+
// The /thinking line for a select value, with --yes (level_chip.js
|
|
59
|
+
// commandLine); null when it is what the form opened with.
|
|
60
|
+
export function commandLine(choice, initial = null) {
|
|
61
|
+
return level.commandLine(CONFIG, choice, initial);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// NEW_CHAT), +summary+ is the model's and +choice+ the level the form
|
|
64
|
-
// shows; +now+ is the summary its "Now:" line reads (refresh passes a
|
|
65
|
-
// newer one, the rest stays as the form opened).
|
|
66
|
-
export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
|
|
67
|
-
const fresh = kind === NEW_CHAT;
|
|
68
|
-
const chosen = (fresh ? choice : summary.own) || DEFAULT;
|
|
69
|
-
const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
|
|
70
|
-
const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
71
|
-
: "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
|
|
72
|
-
return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
|
|
73
|
-
><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
|
|
74
|
-
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
75
|
-
><div class="ctx-pop-meta think-now">${nowText(now)}</div
|
|
76
|
-
><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
|
|
77
|
-
LEVELS.map((level) => option(level, level, chosen))}</select></label
|
|
78
|
-
><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
|
|
79
|
-
><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
64
|
+
// The popover's form (level_chip.js formTemplate).
|
|
65
|
+
export function formTemplate(summary, opts) {
|
|
66
|
+
return level.formTemplate(CONFIG, summary, opts);
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
// The chip's popover
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// NEW_CHAT): summary() is the picked model's, choice() the pending level
|
|
87
|
-
// and choose(next) takes a Set's (null for "follow the model"). id and
|
|
88
|
-
// anchor (the chip's selector) keep it apart from the llm ctx control on
|
|
89
|
-
// the same element.
|
|
90
|
-
export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
|
|
91
|
-
kind = SESSION, choice = () => null, choose = null,
|
|
92
|
-
id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
|
|
93
|
-
doc = document, win = window }) {
|
|
94
|
-
const fresh = kind === NEW_CHAT;
|
|
95
|
-
const pop = doc.createElement("div");
|
|
96
|
-
pop.id = id;
|
|
97
|
-
pop.className = "ctx-popover think-popover";
|
|
98
|
-
pop.setAttribute("role", "dialog");
|
|
99
|
-
pop.hidden = true;
|
|
100
|
-
doc.body.appendChild(pop);
|
|
101
|
-
let initial = null;
|
|
102
|
-
let openFor = null;
|
|
103
|
-
// What the open form was drawn from: the model's or session's summary
|
|
104
|
-
// and the form's options. A closed popover is empty, so each open draws
|
|
105
|
-
// a fresh form (an unsent pick is gone).
|
|
106
|
-
let shown = null;
|
|
107
|
-
const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
|
|
108
|
-
|
|
109
|
-
function place(anchor) {
|
|
110
|
-
const width = Math.min(320, win.innerWidth - 32);
|
|
111
|
-
pop.style.width = `${width}px`;
|
|
112
|
-
pop.style.maxHeight = "";
|
|
113
|
-
const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
|
|
114
|
-
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
115
|
-
pop.style.left = `${at.left}px`;
|
|
116
|
-
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
117
|
-
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
118
|
-
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function open(anchor) {
|
|
122
|
-
const data = summary();
|
|
123
|
-
if (!data) return;
|
|
124
|
-
initial = (fresh ? choice() : data.own) || DEFAULT;
|
|
125
|
-
openFor = session();
|
|
126
|
-
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
127
|
-
draw(data);
|
|
128
|
-
pop.hidden = false;
|
|
129
|
-
place(anchor);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function close() {
|
|
133
|
-
pop.hidden = true;
|
|
134
|
-
initial = null;
|
|
135
|
-
openFor = null;
|
|
136
|
-
shown = null;
|
|
137
|
-
render(nothing, pop);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isOpen() { return !pop.hidden; }
|
|
141
|
-
|
|
142
|
-
// A new summary: an open form keeps the user's pick; only "Now:" follows
|
|
143
|
-
// (the same template again: lit writes that line alone).
|
|
144
|
-
function refresh() {
|
|
145
|
-
if (!isOpen()) return;
|
|
146
|
-
const data = summary();
|
|
147
|
-
if (!data || session() !== openFor) return close();
|
|
148
|
-
draw(data);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
el.addEventListener("click", (e) => {
|
|
152
|
-
const anchor = e.target.closest(anchorSel);
|
|
153
|
-
if (!anchor) return;
|
|
154
|
-
e.stopPropagation();
|
|
155
|
-
if (isOpen()) close();
|
|
156
|
-
else open(anchor);
|
|
157
|
-
});
|
|
158
|
-
pop.addEventListener("submit", (e) => {
|
|
159
|
-
e.preventDefault();
|
|
160
|
-
const level = e.target.elements.level.value;
|
|
161
|
-
const was = initial;
|
|
162
|
-
close();
|
|
163
|
-
if (fresh) return choose(level === DEFAULT ? null : level);
|
|
164
|
-
const line = commandLine(level, was);
|
|
165
|
-
if (line) run(line);
|
|
166
|
-
});
|
|
167
|
-
pop.addEventListener("click", (e) => {
|
|
168
|
-
if (e.target.closest("[data-act]")?.dataset.act === "close") close();
|
|
169
|
-
});
|
|
170
|
-
doc.addEventListener("click", (e) => {
|
|
171
|
-
if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
|
|
172
|
-
});
|
|
173
|
-
doc.addEventListener("keydown", (e) => {
|
|
174
|
-
if (e.key === "Escape" && isOpen()) close();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
return { close, refresh, get open() { return isOpen(); } };
|
|
69
|
+
// The chip's popover (level_chip.js createLevelControl); the start page's
|
|
70
|
+
// passes kind NEW_CHAT, choice and choose.
|
|
71
|
+
export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
|
|
72
|
+
return level.createLevelControl({ cfg: CONFIG, id, ...opts });
|
|
178
73
|
}
|
|
@@ -8,6 +8,7 @@ import { copySourceAttr } from "./copy.js";
|
|
|
8
8
|
import { activityDiffHtml } from "./diff_view.js";
|
|
9
9
|
import { escapeHtml, userBodyHtml } from "./format.js";
|
|
10
10
|
import { refAttrs } from "./refs.js";
|
|
11
|
+
import { readLinesHtml } from "./read_view.js";
|
|
11
12
|
import { formatDuration } from "./timing.js";
|
|
12
13
|
import { emptyAnswerLine } from "./turn_events.js";
|
|
13
14
|
|
|
@@ -72,7 +73,8 @@ function llmEditHtml(row) {
|
|
|
72
73
|
// else the full params line, on hover), the word an exception shows, the
|
|
73
74
|
// call's duration, the full command (an execute/task_create's view, also
|
|
74
75
|
// while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
|
|
75
|
-
// the rest on hover
|
|
76
|
+
// the rest on hover; a read of the file as-is also its numbered lines,
|
|
77
|
+
// closed: read_view.js), its images (+thumbs+(images) draws them) and an
|
|
76
78
|
// edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
|
|
77
79
|
// part with nothing to say is left out.
|
|
78
80
|
export function toolRowHtml(row, { thumbs = () => "" } = {}) {
|
|
@@ -96,7 +98,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
|
|
|
96
98
|
const status = statusParts(row.status, { noMatch: !!row.no_match });
|
|
97
99
|
return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
|
|
98
100
|
`${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
99
|
-
`${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
|
|
101
|
+
`${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}${output ? readLinesHtml(row) : ""}` +
|
|
100
102
|
`${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -63,13 +63,15 @@ export function thinkingTemplate({ body, wrapped = true, line = null, cls = "thi
|
|
|
63
63
|
|
|
64
64
|
// A step's narration: the live box of complete sentences (one per line,
|
|
65
65
|
// the newest last), the live "…" until the first one, or a closed step's
|
|
66
|
-
// full text. None when there is nothing to say.
|
|
66
|
+
// full text. None when there is nothing to say. A closed step's text is
|
|
67
|
+
// set whole (.textContent): its refs (ref_scan.js) split it into spans
|
|
68
|
+
// that a text part's update would leave behind.
|
|
67
69
|
function textTemplate(text, { live, sentences, placeholder }) {
|
|
68
70
|
if (live && sentences?.length) {
|
|
69
71
|
return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
|
|
70
72
|
}
|
|
71
73
|
if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
|
|
72
|
-
return text ? html`<div class="gen-text"
|
|
74
|
+
return text ? html`<div class="gen-text" .textContent=${text}></div>` : nothing;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
|
|
@@ -118,7 +120,7 @@ function rawNodes(markup) {
|
|
|
118
120
|
// The parts of a drawn tool row a user may have changed (the command's
|
|
119
121
|
// raw toggle, an opened diff) or that never change once there (the
|
|
120
122
|
// thumbs): a redraw keeps them as they are.
|
|
121
|
-
const KEPT_PARTS = ["activity-command", "activity-diff", "thumbs"];
|
|
123
|
+
const KEPT_PARTS = ["activity-command", "activity-diff", "activity-read", "thumbs"];
|
|
122
124
|
|
|
123
125
|
const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains(cls));
|
|
124
126
|
|
data/lib/samagotchi/web/token.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
require_relative "../atomic_file"
|
|
6
6
|
|
|
7
7
|
require_relative "../session"
|
|
8
|
+
require_relative "../redacted_inspect"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Web
|
|
@@ -62,6 +63,8 @@ module Samagotchi
|
|
|
62
63
|
# its mtime changes (one stat per check), so `chi web --new-token`
|
|
63
64
|
# takes effect at once, without a restart.
|
|
64
65
|
class Source
|
|
66
|
+
include RedactedInspect
|
|
67
|
+
|
|
65
68
|
attr_reader :path
|
|
66
69
|
|
|
67
70
|
def initialize(path = Token.path)
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -693,7 +693,8 @@ module Samagotchi
|
|
|
693
693
|
resolved = @continue_offer.resolved?(awaiting, result)
|
|
694
694
|
@engine.synchronize_events do
|
|
695
695
|
@continue_offer.announce_resolved(result, command) if resolved
|
|
696
|
-
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed)
|
|
696
|
+
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
697
|
+
confirm: result.confirm)
|
|
697
698
|
shown.each { |event| @engine.announce(event) }
|
|
698
699
|
end
|
|
699
700
|
save_or_log(:command) { save_session } unless Array(result.changed).empty?
|
|
@@ -706,9 +707,14 @@ module Samagotchi
|
|
|
706
707
|
# the cards and notices it showed, held: they follow its command_ran,
|
|
707
708
|
# as its output
|
|
708
709
|
def run_command_line(command)
|
|
709
|
-
@engine.holding_announcements { command_result(command) { @commands.run(command.line) } }
|
|
710
|
+
@engine.holding_announcements { command_result(command) { @commands.run(command.line, confirm: confirm?(command)) } }
|
|
710
711
|
end
|
|
711
712
|
|
|
713
|
+
# Whether +command+ asks before a costly /thinking or /reasoning-history
|
|
714
|
+
# change (CacheConfirm): only a person at a web tab or a TUI sees the
|
|
715
|
+
# question; chi send, a delegate, a plugin and a card's action apply it.
|
|
716
|
+
def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
|
|
717
|
+
|
|
712
718
|
# The block's Result for +command+; one that raised, or found no
|
|
713
719
|
# command for the line, is an error Result saying so.
|
|
714
720
|
# @return [SessionCommands::Result]
|
|
@@ -767,9 +773,9 @@ module Samagotchi
|
|
|
767
773
|
# saved.
|
|
768
774
|
def start_anytime_command(command)
|
|
769
775
|
@engine.spawn_anytime do
|
|
770
|
-
result = command_result(command) { @engine.running_anytime { @commands.run(command.line) } }
|
|
776
|
+
result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
|
|
771
777
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
772
|
-
anytime: true)
|
|
778
|
+
anytime: true, confirm: result.confirm)
|
|
773
779
|
rescue StandardError => e
|
|
774
780
|
Log.warn(:worker, "anytime_command_failed", line: command.line, error: e.class.name, msg: e.message)
|
|
775
781
|
end
|
|
@@ -777,7 +783,9 @@ module Samagotchi
|
|
|
777
783
|
|
|
778
784
|
# @param anytime [Boolean] an anytime command's: its line was shown at
|
|
779
785
|
# its command_queued already
|
|
780
|
-
|
|
786
|
+
# @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
|
|
787
|
+
# line that applies it (the TUI's prefill, the web's Apply button)
|
|
788
|
+
def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
|
|
781
789
|
text = output.to_s
|
|
782
790
|
event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
|
|
783
791
|
changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
|
|
@@ -788,6 +796,10 @@ module Samagotchi
|
|
|
788
796
|
event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
|
|
789
797
|
# /thinking or /model: the level the next turn runs at (the web's chip).
|
|
790
798
|
event[:thinking] = @engine.thinking_explained&.summary if changed.intersect?(%i[thinking model])
|
|
799
|
+
# /reasoning-history or /model: the level the next turn runs under
|
|
800
|
+
# (the web's chip); nil on a native host, which ignores it.
|
|
801
|
+
event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
|
|
802
|
+
event[:confirm] = confirm.to_h if confirm
|
|
791
803
|
event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
|
|
792
804
|
@engine.announce(event)
|
|
793
805
|
end
|