samagotchi 0.52.0 → 0.54.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 +58 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +1 -1
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +35 -44
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +132 -135
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +184 -190
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +4 -28
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +45 -257
- data/lib/samagotchi/session_metrics.rb +18 -21
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +25 -3
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -8
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -8
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/thinking_chip.js +31 -21
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +9 -1
|
@@ -43,8 +43,10 @@ module Samagotchi
|
|
|
43
43
|
action: tool_activity_action(tool_name, registry: registry, call: call),
|
|
44
44
|
tool: tool_name,
|
|
45
45
|
params: tool_activity_params(tool_name, call, registry: registry),
|
|
46
|
-
status: tool_activity_status(result, tool_name)
|
|
46
|
+
status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
|
|
47
47
|
}
|
|
48
|
+
# A grep that found nothing: ok, and the web row says "no match".
|
|
49
|
+
event[:no_match] = true if event[:status] == "ok" && tool_name == Tools::Execute::NAME && execute_failed?(result.to_s)
|
|
48
50
|
title = tool_title(tool_name, call, cwd: cwd)
|
|
49
51
|
event[:title] = title if title
|
|
50
52
|
# The name the model used when an alias ran a built-in (called_as: "bash"
|
|
@@ -181,13 +183,6 @@ module Samagotchi
|
|
|
181
183
|
end
|
|
182
184
|
end
|
|
183
185
|
|
|
184
|
-
# "error" for an "Error:" result, and for an execute whose command
|
|
185
|
-
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
186
|
-
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
187
|
-
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
188
|
-
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
189
|
-
# and for a command the user's Stop killed or kept from starting: a
|
|
190
|
-
# cancel, not a failure (its text still starts "Error:" for the model).
|
|
191
186
|
# memory_write's label: a call with only a description changes the index line.
|
|
192
187
|
def memory_write_action(call)
|
|
193
188
|
return "saving memory" unless call.is_a?(Hash)
|
|
@@ -205,23 +200,98 @@ module Samagotchi
|
|
|
205
200
|
request.ids.empty? && request.restore? ? "restoring outputs" : "forgetting outputs"
|
|
206
201
|
end
|
|
207
202
|
|
|
208
|
-
|
|
203
|
+
# "error" for an "Error:" result, and for an execute whose command
|
|
204
|
+
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
205
|
+
# signal), so the turn tally's "(N failed)" counts it; but not a grep
|
|
206
|
+
# that found nothing (no_match?, given the +command+): that stays "ok".
|
|
207
|
+
# "stopped" for a
|
|
208
|
+
# task_wait the user's Stop ended (the task itself runs on), for one
|
|
209
|
+
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
210
|
+
# and for a command the user's Stop killed or kept from starting: a
|
|
211
|
+
# cancel, not a failure (its text still starts "Error:" for the model).
|
|
212
|
+
def tool_activity_status(result, tool_name = nil, command: nil)
|
|
209
213
|
text = result.to_s
|
|
210
214
|
return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
|
|
211
215
|
return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
|
|
212
216
|
return "error" if text.start_with?("Error:")
|
|
213
|
-
|
|
217
|
+
if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
218
|
+
return no_match?(text, command) ? "ok" : "error"
|
|
219
|
+
end
|
|
214
220
|
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
215
221
|
|
|
216
222
|
"ok"
|
|
217
223
|
end
|
|
218
224
|
|
|
219
225
|
def execute_failed?(text)
|
|
220
|
-
|
|
221
|
-
|
|
226
|
+
code = execute_exit_code(text)
|
|
227
|
+
!code.nil? && code != "0"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The code on an execute result's last "exit: N" line ("" when killed
|
|
231
|
+
# by a signal); nil with no exit line.
|
|
232
|
+
def execute_exit_code(text)
|
|
233
|
+
exit_line = text.to_s.lines.reverse_each.find { |line| line.start_with?("exit: ") }
|
|
234
|
+
exit_line && exit_line[/\Aexit: (\d*)/, 1]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The grep family: exit 1 means "searched, found nothing".
|
|
238
|
+
NO_MATCH_COMMANDS = %w[grep egrep fgrep rg ag pgrep].freeze
|
|
239
|
+
# Commands that can fail without a message, so an "&&" after them may
|
|
240
|
+
# hide which step exited 1.
|
|
241
|
+
SILENT_FAILERS = ["test", "[", "[[", "false", "diff", "cmp", "which", "type", "hash"].freeze
|
|
242
|
+
QUIET_FLAGS = %w[-q --quiet -s].freeze
|
|
243
|
+
# Any output sent to /dev/null (">", "1>", "2>", ">>"): its message may be gone.
|
|
244
|
+
TO_DEV_NULL = %r{>\s*/dev/null}
|
|
245
|
+
# Commands that end the script with their own code: the exit 1 may be theirs.
|
|
246
|
+
EXITS = %w[exit return].freeze
|
|
247
|
+
|
|
248
|
+
# An execute that exited 1 with no stderr block, whose last step is a
|
|
249
|
+
# grep-family command, and where no "&&" follows a step that could have
|
|
250
|
+
# failed silently: a search that found nothing, not a failure. Also
|
|
251
|
+
# false with a background "&" step (CommandSteps splits "&>" there too),
|
|
252
|
+
# an earlier exit/return, or set -e (any earlier step's failure ends the
|
|
253
|
+
# script). When unsure (no command, a command CommandSteps can't read),
|
|
254
|
+
# false.
|
|
255
|
+
def no_match?(text, command)
|
|
256
|
+
return false if command.nil? || execute_exit_code(text) != "1"
|
|
257
|
+
return false if text.to_s.each_line.any? { |line| line.chomp == "stderr:" }
|
|
258
|
+
|
|
259
|
+
steps = CommandSteps.parse(command)&.steps
|
|
260
|
+
return false unless steps && NO_MATCH_COMMANDS.include?(step_words(steps.last).first)
|
|
261
|
+
return false if steps.any? { |step| step.op == "&" }
|
|
262
|
+
return false if steps[0...-1].any? { |step| ends_script?(step) }
|
|
263
|
+
|
|
264
|
+
steps.each_cons(2).none? { |step, after| after.op == "&&" && silent_failer?(step) }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A step's words from its command word on: no NAME=value prefixes, the
|
|
268
|
+
# command word without its path.
|
|
269
|
+
def step_words(step)
|
|
270
|
+
words = Guardrails::ShellLex.lex(step.text).filter_map { |kind, value| value if kind == :word }
|
|
271
|
+
words = words.drop_while { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=/) }
|
|
272
|
+
return [] if words.empty?
|
|
273
|
+
|
|
274
|
+
[File.basename(words.first), *words.drop(1)]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# exit / return, or set -e (-e alone or in a flag group, -o errexit).
|
|
278
|
+
def ends_script?(step)
|
|
279
|
+
name, *args = step_words(step)
|
|
280
|
+
return true if EXITS.include?(name)
|
|
281
|
+
return false unless name == "set"
|
|
282
|
+
|
|
283
|
+
args.any? { |arg| arg.match?(/\A-[a-z]*e/) } || args.each_cons(2).include?(%w[-o errexit])
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def silent_failer?(step)
|
|
287
|
+
return true if step.text.match?(TO_DEV_NULL) || step.text.lstrip.start_with?("((")
|
|
288
|
+
|
|
289
|
+
name, *args = step_words(step)
|
|
290
|
+
return true if SILENT_FAILERS.include?(name)
|
|
291
|
+
return true if name == "command" && args.intersect?(%w[-v -V])
|
|
292
|
+
return true if NO_MATCH_COMMANDS.include?(name) && args.intersect?(QUIET_FLAGS)
|
|
222
293
|
|
|
223
|
-
|
|
224
|
-
code != "0"
|
|
294
|
+
name == "git" && args.first == "diff" && args.intersect?(%w[--quiet --exit-code])
|
|
225
295
|
end
|
|
226
296
|
|
|
227
297
|
def tool_activity_params(tool_name, call, registry: nil)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "uri"
|
|
|
9
9
|
require "rack"
|
|
10
10
|
require "rack/request"
|
|
11
11
|
|
|
12
|
+
require_relative "../analytics_file"
|
|
12
13
|
require_relative "../client_id"
|
|
13
14
|
require_relative "../delivery"
|
|
14
15
|
require_relative "../answer_tail"
|
|
@@ -697,7 +698,7 @@ module Samagotchi
|
|
|
697
698
|
# the new link started is not a refusal of it.
|
|
698
699
|
def continue_from(given, prompt:, title:)
|
|
699
700
|
[@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
|
|
700
|
-
rescue
|
|
701
|
+
rescue SessionContinue::Refused => e
|
|
701
702
|
extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
|
|
702
703
|
[nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
|
|
703
704
|
rescue SessionManager::ArchiveRefused => e
|
|
@@ -1722,13 +1723,7 @@ module Samagotchi
|
|
|
1722
1723
|
end
|
|
1723
1724
|
|
|
1724
1725
|
def read_analytics(session_id)
|
|
1725
|
-
|
|
1726
|
-
return {} unless File.file?(path)
|
|
1727
|
-
|
|
1728
|
-
data = JSON.parse(File.read(path))
|
|
1729
|
-
data.is_a?(Hash) ? data : {}
|
|
1730
|
-
rescue JSON::ParserError, SystemCallError
|
|
1731
|
-
{}
|
|
1726
|
+
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
1732
1727
|
end
|
|
1733
1728
|
|
|
1734
1729
|
def merge_timing_records(persisted, live)
|
|
@@ -119,6 +119,8 @@ export function addCompleted(model, event = {}, now = null) {
|
|
|
119
119
|
if (event.activity?.params) row.params = event.activity.params;
|
|
120
120
|
if (event.activity?.title) row.title = event.activity.title;
|
|
121
121
|
if (event.activity?.called_as) row.called_as = event.activity.called_as;
|
|
122
|
+
// A grep that found nothing (status ok): the row says "no match".
|
|
123
|
+
if (event.activity?.no_match) row.no_match = true;
|
|
122
124
|
// The images the tool returned (a reload in the turn view draws them from
|
|
123
125
|
// the saved parts, turn_html.js toolRowHtml).
|
|
124
126
|
if (event.images?.length) row.images = event.images;
|
|
@@ -48,7 +48,7 @@ import { parsePresets } from "./annotate_presets.js";
|
|
|
48
48
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
49
49
|
import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
50
50
|
import { routeChunk } from "./chunk_router.js";
|
|
51
|
-
import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, generationTokens, lastSpeedText, speedText
|
|
51
|
+
import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, ctxBarTip, generationTokens, lastSpeedText, speedText } from "./ctx.js";
|
|
52
52
|
import { createQuestionCards } from "./question_cards.js";
|
|
53
53
|
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
54
54
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
@@ -126,7 +126,6 @@ const llmContextNewControl = createLlmContextControl({
|
|
|
126
126
|
// in the session's worker.
|
|
127
127
|
const thinkingControl = createThinkingControl({
|
|
128
128
|
el: infoTextEl,
|
|
129
|
-
escapeHtml,
|
|
130
129
|
summary: () => (selected ? current.thinking : null),
|
|
131
130
|
session: () => selected,
|
|
132
131
|
running: () => liveTurn.running,
|
|
@@ -139,7 +138,6 @@ let newChatThinking = null;
|
|
|
139
138
|
const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
140
139
|
const thinkingNewControl = createThinkingControl({
|
|
141
140
|
el: thinkingNewEl,
|
|
142
|
-
escapeHtml,
|
|
143
141
|
summary: newChatThinkingSummary,
|
|
144
142
|
kind: NEW_CHAT,
|
|
145
143
|
choice: () => newChatThinking,
|
|
@@ -464,7 +462,7 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
|
|
|
464
462
|
const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
|
|
465
463
|
const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
|
|
466
464
|
const ctx = cardCtxText(s.ctx_pct);
|
|
467
|
-
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(
|
|
465
|
+
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct }))}">${escapeHtml(ctx)}</span>` : "";
|
|
468
466
|
const waiting = waitingBadge(s);
|
|
469
467
|
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
470
468
|
// A turn a hook stopped wears its own badge, unless the session waits on
|
|
@@ -2023,9 +2021,9 @@ function updateInfoBar() {
|
|
|
2023
2021
|
const idShort = selected.slice(0,8);
|
|
2024
2022
|
const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
|
|
2025
2023
|
const tokens = current.timing.tokens;
|
|
2026
|
-
const ctxTip =
|
|
2027
|
-
|
|
2028
|
-
|
|
2024
|
+
const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
|
|
2025
|
+
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
2026
|
+
budget: current.llmContext?.budget_tokens });
|
|
2029
2027
|
const speedTextNow = lastSpeedText(tokens);
|
|
2030
2028
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
2031
2029
|
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
@@ -2051,7 +2049,7 @@ function updateInfoBar() {
|
|
|
2051
2049
|
const attachCmd = `chi --attach ${selected}`;
|
|
2052
2050
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
2053
2051
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
2054
|
-
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span
|
|
2052
|
+
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span><span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
|
|
2055
2053
|
// The session clock ticks every second while a turn runs: only its text
|
|
2056
2054
|
// changes, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
2057
2055
|
renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
|
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
// The commands to offer while the composer holds one "/word" being typed
|
|
6
6
|
// (no space or newline yet): the session's slash commands that start with
|
|
7
|
-
// it, by name.
|
|
7
|
+
// it, by name. A UI's own (local) one is offered only when its uis name
|
|
8
|
+
// the web (/cut); the terminals' own (/stats, /exit …) don't run here.
|
|
8
9
|
export function commandMatches(commands, text) {
|
|
9
10
|
const typed = String(text || "");
|
|
10
11
|
if (!/^\/\S*$/.test(typed)) return [];
|
|
11
12
|
const needle = typed.toLowerCase();
|
|
12
13
|
return (Array.isArray(commands) ? commands : [])
|
|
13
|
-
.filter((c) => c &&
|
|
14
|
+
.filter((c) => c && runsInWeb(c) && typeof c.name === "string" && c.name.startsWith("/"))
|
|
14
15
|
.filter((c) => c.name.toLowerCase().startsWith(needle))
|
|
15
16
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
function runsInWeb(command) {
|
|
20
|
+
return !command.local || (Array.isArray(command.uis) && command.uis.includes("web"));
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
// What picking +name+ does to the composer: {text, send}. The name
|
|
19
24
|
// completes with a space for its arguments; ⏎ on a name already typed in
|
|
20
25
|
// full sends it as it is (⏎ on "/help" runs /help).
|
|
@@ -62,9 +62,11 @@ export function savedCtxPct(context, budgetTokens = null) {
|
|
|
62
62
|
return (used / against) * 100;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// A session card's quiet ctx: "12%",
|
|
65
|
+
// A session card's quiet ctx: "12%", "ctx ?" when the list row has none (the
|
|
66
|
+
// chip is always shown; "ctx ?" never means 0 — no request in this session
|
|
67
|
+
// yet; a lone "?" beside the folder would read as "? project").
|
|
66
68
|
export function cardCtxText(pct) {
|
|
67
|
-
return num(pct) === null ? "" : `${Math.round(pct)}%`;
|
|
69
|
+
return num(pct) === null ? "ctx ?" : `${Math.round(pct)}%`;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
// A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
|
|
@@ -123,12 +125,24 @@ export function memoryIndexText(memoryIndex) {
|
|
|
123
125
|
|
|
124
126
|
// The info bar's ctx: "ctx 12%", "ctx ~12%" when the window is chi's
|
|
125
127
|
// default (no server, model list or setting gave one, so the percent is of
|
|
126
|
-
// a guess); ""
|
|
128
|
+
// a guess); "ctx ?" when there is no percent yet (no request in this session
|
|
129
|
+
// — the chip is always shown; "?" never means 0).
|
|
127
130
|
export function ctxBarText(pct, windowSource = null) {
|
|
128
|
-
if (num(pct) === null) return "";
|
|
131
|
+
if (num(pct) === null) return "ctx ?";
|
|
129
132
|
return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
// The info bar's / the card's ctx tooltip: the window line under the title
|
|
136
|
+
// when there's a number (tokensTipText), or why there's none — "No request
|
|
137
|
+
// in this session yet" — when the page has no percent (before any request;
|
|
138
|
+
// the page isn't told context.status is off, and the window always resolves,
|
|
139
|
+
// so "?" never means 0).
|
|
140
|
+
export function ctxBarTip(tokens, memoryIndex, window) {
|
|
141
|
+
if (num(window?.pct) === null) return "No request in this session yet";
|
|
142
|
+
|
|
143
|
+
return tokensTipText(tokens, undefined, memoryIndex, window);
|
|
144
|
+
}
|
|
145
|
+
|
|
132
146
|
// The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
|
|
133
147
|
// session's window, where it came from, the meter's value and the LLM
|
|
134
148
|
// context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
|
|
@@ -136,7 +136,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
136
136
|
/* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
|
|
137
137
|
no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
|
|
138
138
|
blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
|
|
139
|
-
An exception also shows its word after the title (.activity-state)
|
|
139
|
+
An exception also shows its word after the title (.activity-state); a done grep that found nothing a muted
|
|
140
|
+
"no match" (.no-match). */
|
|
140
141
|
.activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
|
|
141
142
|
.activity-status.ok::before{content:"\2713";opacity:.85}
|
|
142
143
|
.activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
|
|
@@ -149,6 +150,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
149
150
|
:is(.activity-status,.activity-state).error{color:var(--danger)}
|
|
150
151
|
:is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
|
|
151
152
|
:is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
|
|
153
|
+
.activity-state.no-match{color:var(--text-muted);font-weight:500}
|
|
152
154
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
153
155
|
/* The stop-task button on a running task_wait (task_stop.js): a small ghost
|
|
154
156
|
button in the danger colours, at the row's end and in the stage's now slot. */
|
|
@@ -328,7 +330,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
328
330
|
.question-freeform{width:100%;box-sizing:border-box;background:var(--bg-inset);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;margin-bottom:8px}
|
|
329
331
|
.question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
|
|
330
332
|
.question-error.hidden{display:none}
|
|
331
|
-
.question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
|
|
333
|
+
.question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
332
334
|
/* 4.02: a resolved card whose summary already carries the result hides the
|
|
333
335
|
body line; a hand-toggled one keeps it (its expanded body shows it). */
|
|
334
336
|
.question-result.hidden{display:none}
|
|
@@ -145,7 +145,9 @@ export function truncate(text, n = 80) {
|
|
|
145
145
|
// asks its tool and command: "execute: rm -rf tmp → Denied").
|
|
146
146
|
export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
147
147
|
if (cancelled || answer) {
|
|
148
|
-
|
|
148
|
+
// A deny's typed reason stays out of the one-liner (the body line has it).
|
|
149
|
+
const result = !cancelled && deniedReason(pq, answer) ? "Denied"
|
|
150
|
+
: resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
|
|
149
151
|
const relay = relayView(pq);
|
|
150
152
|
const asked = summaryAsked(pq);
|
|
151
153
|
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
@@ -165,6 +167,12 @@ function summaryAsked(pq) {
|
|
|
165
167
|
return String(pq.question || pq.header || "").split("\n")[0];
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
// The reason typed with an approval's deny ("" when none, or not a deny).
|
|
171
|
+
export function deniedReason(pq, answer) {
|
|
172
|
+
if (approvalAllowed(pq, answer) !== false) return "";
|
|
173
|
+
return answer && answer.freeform ? String(answer.freeform) : "";
|
|
174
|
+
}
|
|
175
|
+
|
|
168
176
|
// Whether the answer allowed the call (the card turns green) or denied it.
|
|
169
177
|
export function approvalAllowed(pq, answer) {
|
|
170
178
|
if (!isApproval(pq)) return null;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
17
|
import {
|
|
18
|
-
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
|
|
18
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, nextCardAction,
|
|
19
19
|
relayedToView, resultText, summaryText,
|
|
20
20
|
} from "./question_card.js";
|
|
21
21
|
import { sessionHash } from "./route.js";
|
|
@@ -339,8 +339,10 @@ export function createQuestionCards({
|
|
|
339
339
|
if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
|
|
340
340
|
// 4.02: a reopened card's summary already carries the result (the
|
|
341
341
|
// snapshot re-renders it resolved), and the body line repeats it. It
|
|
342
|
-
// stays for a hand-toggled card, whose expanded body the user looks at
|
|
343
|
-
|
|
342
|
+
// stays for a hand-toggled card, whose expanded body the user looks at,
|
|
343
|
+
// and for a deny with a reason: the summary says only "Denied".
|
|
344
|
+
const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
|
|
345
|
+
if (summary && !keepNote) note.classList.add("hidden");
|
|
344
346
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
345
347
|
pending = null;
|
|
346
348
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// nothing is sent unless the user sets one.
|
|
13
13
|
|
|
14
14
|
import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
|
|
15
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
15
16
|
|
|
16
17
|
export const LEVELS = ["off", "low", "medium", "high"];
|
|
17
18
|
const DEFAULT = "default";
|
|
@@ -36,6 +37,8 @@ export function chipModel(summary, { kind = SESSION } = {}) {
|
|
|
36
37
|
return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// The session bar's chip, as a string: the info bar is still built as one
|
|
41
|
+
// (app.js); it becomes a template when the info bar moves to lit.
|
|
39
42
|
export function chipHtml(summary, escapeHtml) {
|
|
40
43
|
const chip = chipModel(summary);
|
|
41
44
|
if (!chip) return "";
|
|
@@ -50,32 +53,31 @@ export function commandLine(level, initial = null) {
|
|
|
50
53
|
return `/thinking ${level}`;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
function option(value, label, chosen, escapeHtml) {
|
|
54
|
-
return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
56
|
// "Now: low (session)", "Now: default (the model's own)".
|
|
58
57
|
export function nowText(summary) {
|
|
59
58
|
return `Now: ${summary.level} (${summary.source || "the model's own"})`;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
|
|
62
|
+
|
|
63
|
+
// The popover's form (a lit-html template). For the new chat (kind
|
|
64
|
+
// NEW_CHAT), +summary+ is the model's and +choice+ the level the form
|
|
65
|
+
// shows; +now+ is the summary its "Now:" line reads (refresh passes a
|
|
66
|
+
// newer one, the rest stays as the form opened).
|
|
67
|
+
export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
|
|
65
68
|
const fresh = kind === NEW_CHAT;
|
|
66
69
|
const chosen = (fresh ? choice : summary.own) || DEFAULT;
|
|
67
70
|
const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
|
|
68
71
|
const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
69
72
|
: "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
|
|
70
|
-
return `<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`<div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
73
|
+
return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
|
|
74
|
+
><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
|
|
75
|
+
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
76
|
+
><div class="ctx-pop-meta think-now">${nowText(now)}</div
|
|
77
|
+
><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
|
|
78
|
+
LEVELS.map((level) => option(level, level, chosen))}</select></label
|
|
79
|
+
><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
|
|
80
|
+
><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
// The chip's popover, as createLlmContextControl's: el is where the chip
|
|
@@ -86,7 +88,7 @@ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION,
|
|
|
86
88
|
// and choose(next) takes a Set's (null for "follow the model"). id and
|
|
87
89
|
// anchor (the chip's selector) keep it apart from the llm ctx control on
|
|
88
90
|
// the same element.
|
|
89
|
-
export function createThinkingControl({ el,
|
|
91
|
+
export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
|
|
90
92
|
kind = SESSION, choice = () => null, choose = null,
|
|
91
93
|
id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
|
|
92
94
|
doc = document, win = window }) {
|
|
@@ -99,6 +101,11 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
|
|
|
99
101
|
doc.body.appendChild(pop);
|
|
100
102
|
let initial = null;
|
|
101
103
|
let openFor = null;
|
|
104
|
+
// What the open form was drawn from: the model's or session's summary
|
|
105
|
+
// and the form's options. A closed popover is empty, so each open draws
|
|
106
|
+
// a fresh form (an unsent pick is gone).
|
|
107
|
+
let shown = null;
|
|
108
|
+
const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
|
|
102
109
|
|
|
103
110
|
function place(anchor) {
|
|
104
111
|
const width = Math.min(320, win.innerWidth - 32);
|
|
@@ -117,7 +124,8 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
|
|
|
117
124
|
if (!data) return;
|
|
118
125
|
initial = (fresh ? choice() : data.own) || DEFAULT;
|
|
119
126
|
openFor = session();
|
|
120
|
-
|
|
127
|
+
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
128
|
+
draw(data);
|
|
121
129
|
pop.hidden = false;
|
|
122
130
|
place(anchor);
|
|
123
131
|
}
|
|
@@ -126,17 +134,19 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
|
|
|
126
134
|
pop.hidden = true;
|
|
127
135
|
initial = null;
|
|
128
136
|
openFor = null;
|
|
137
|
+
shown = null;
|
|
138
|
+
render(nothing, pop);
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
function isOpen() { return !pop.hidden; }
|
|
132
142
|
|
|
133
|
-
// A new summary: an open form keeps the user's pick; only "Now:" follows
|
|
143
|
+
// A new summary: an open form keeps the user's pick; only "Now:" follows
|
|
144
|
+
// (the same template again: lit writes that line alone).
|
|
134
145
|
function refresh() {
|
|
135
146
|
if (!isOpen()) return;
|
|
136
147
|
const data = summary();
|
|
137
148
|
if (!data || session() !== openFor) return close();
|
|
138
|
-
|
|
139
|
-
if (now) now.textContent = nowText(data);
|
|
149
|
+
draw(data);
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
el.addEventListener("click", (e) => {
|
|
@@ -264,14 +264,20 @@ function canceledSteps(g, record, tools) {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
// A call's row from its tool record alone (no saved parts): the record's
|
|
267
|
-
// called_as too, so "(called as bash)" shows after a reload, as live
|
|
267
|
+
// called_as too, so "(called as bash)" shows after a reload, as live, and
|
|
268
|
+
// its no_match ("no match" on a grep that found nothing).
|
|
268
269
|
function recordRows(tools, iteration) {
|
|
269
270
|
return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
|
|
270
271
|
key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
|
|
271
272
|
...(r.called_as ? { called_as: r.called_as } : {}),
|
|
273
|
+
...noMatch(r),
|
|
272
274
|
}));
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
function noMatch(record) {
|
|
278
|
+
return record?.no_match ? { no_match: true } : {};
|
|
279
|
+
}
|
|
280
|
+
|
|
275
281
|
// A turn whose saved messages carry their parts (the server's ?parts=1):
|
|
276
282
|
// every assistant message is one step. Its calls take the turn's records by
|
|
277
283
|
// tool, in call order (each the next record of its tool, consumed with the
|
|
@@ -292,7 +298,7 @@ function partsTurn(g, items, record, tools, stepsOnly = false) {
|
|
|
292
298
|
const rows = Array.isArray(parts.tools)
|
|
293
299
|
? parts.tools.map((call, j) => {
|
|
294
300
|
const r = takeRecord(pool, call.tool);
|
|
295
|
-
return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
|
|
301
|
+
return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...noMatch(r), ...call };
|
|
296
302
|
})
|
|
297
303
|
: [];
|
|
298
304
|
const thinking = String(parts.thinking || "");
|
|
@@ -12,16 +12,20 @@ import { emptyAnswerLine } from "./turn_events.js";
|
|
|
12
12
|
|
|
13
13
|
// A row's status (live, or a saved tool record's on reload): running,
|
|
14
14
|
// error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
|
|
15
|
-
// denied it), else ok ("done"
|
|
15
|
+
// denied it), else ok ("done"; a grep that found nothing is ok too, with
|
|
16
|
+
// a "no match" word, see statusParts).
|
|
16
17
|
const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
|
|
17
18
|
|
|
18
19
|
// A row's status as its parts: the mark's class (CSS draws the glyph), its
|
|
19
20
|
// label (aria-label and title) and the word an exception shows after the
|
|
20
|
-
// title (empty for done and running).
|
|
21
|
-
|
|
21
|
+
// title (empty for done and running). A done grep that found nothing
|
|
22
|
+
// (+noMatch+, the activity's no_match) keeps the done mark and shows a
|
|
23
|
+
// muted "no match" word (its own class, wordCls).
|
|
24
|
+
export function statusParts(status, { noMatch = false } = {}) {
|
|
22
25
|
const named = NAMED_STATUSES.includes(status);
|
|
23
26
|
const cls = named ? status : "ok";
|
|
24
27
|
const label = named ? status : "done";
|
|
28
|
+
if (cls === "ok" && noMatch) return { cls, label, word: "no match", wordCls: "no-match" };
|
|
25
29
|
return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -29,8 +33,8 @@ function statusMarkHtml({ cls, label }) {
|
|
|
29
33
|
return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
function statusWordHtml({ cls, word }) {
|
|
33
|
-
return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
|
|
36
|
+
function statusWordHtml({ cls, word, wordCls }) {
|
|
37
|
+
return word ? `<span class="activity-state ${wordCls || cls}">${word}</span>` : "";
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// A reloaded row's ✂ mark: what an LLM context edit saved on its output
|
|
@@ -85,7 +89,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
|
|
|
85
89
|
const output = full
|
|
86
90
|
? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
|
|
87
91
|
: "";
|
|
88
|
-
const status = statusParts(row.status);
|
|
92
|
+
const status = statusParts(row.status, { noMatch: !!row.no_match });
|
|
89
93
|
return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
|
|
90
94
|
`${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
91
95
|
`${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
|