micro-models-agent 2.15.0 → 2.16.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - **Web UI incoming message queue** (`src/modules/webui/state.ts`, `util.ts`, `forward.ts`, `module.ts`, `server.ts`, `run-agent.ts`, `assets/js/tabs.js`): a message sent mid-turn no longer dies on `409 busy` — it queues (`202 {queued: n}`, cap `MAX_QUEUE_LENGTH = 5`, overflow `429 queue_full`) and runs as the next turn on `turn_end`, in order. Drain reuses the single-flight submit path; the queue clears on server stop; `GET /api/state` and SSE `state` carry `queueLen`. The composer stays editable during a turn with a `↳ queued · position N` receipt (`web.input.queued/queue_full`, en+ru; dead `web.input.busy` removed). Docs: `docs/agents/modules.md`. Tests: `tests/webui-server.test.ts`, `tests/webui-auth.test.ts`.
12
+
13
+ - **Web Trace: diagnostics coverage + full model text** (`src/modules/webui/log-api.ts`, `sessions-api.ts`, `forward.ts`, `state.ts`, `module.ts`, `assets/app.js`, `assets/js/sidebar.js`): Trace shows status rows (SSE `meta`), warn/error logs (SSE `log`, minus info-diagnostic chatter), draft retractions, and index start/done/error — plus critic/MoE/compaction/context-probe/usage/audit/claim-retry/output pulled incrementally from `session.jsonl` on every `turn_end` via the new `GET /api/sessions/:id/log` (`?types=&since=&limit=`, field whitelist, secret scrub, no new producers). `turn_end` carries a server `turnSeq`; text details show the full turn text (honest 20K cap instead of a 4K tail slice) with session/turn and a session-log provenance footer (assistant count, iterations, model/tokens). Git ops stay inside `bash` rows (prefix heuristics too fragile). i18n `web.sidebar.trace_*/detail_*` (en+ru). Tests: `tests/webui-log.test.ts`, e2e `web-boot` (Trace panel + index lifecycle).
14
+
15
+ ### Fixed
16
+
17
+ - **Web file list is always expanded with full root-relative links** (`src/modules/webui/assets/js/feed.js`, `styles.css`): consecutive file rows no longer collapse into an expandable `│ N files` group (its chevron toggle failed to open) — every file tool renders its own `◇ <label> · <full-path-link> [copy] <status>` line, and the link text is the full workspace-relative path (`displayPathFromRoot`, absolute paths stripped by the workspace prefix) instead of basename + dim tail. Consecutive file/action rows are compact (`~4px` via `margin-top: -7px` compensating the `11px` feed gap, `line-height: 1.35`). Group CSS/JS removed. Tests: `tests/webui-assets.test.ts`, `tests/e2e/web-filecard.e2e-test.ts`.
18
+ - **Web transcript edges and answer separation** (`src/modules/webui/assets/styles.css`): scroll edge fades are glued exactly to the transcript edges — vertical breathing moved from the scroller to `.feed` (sticky pseudos are constrained to the padding-free content box, previously leaving a gap under the header and above the composer) and hidden fades take zero flow space (`-33px` margins neutralizing height + flex gap). Finished assistant answers carry a quiet accent rail (`.feed > .md/.ans`, `border-left: 2px solid var(--accent)`, mirroring the user block) so the result reads instantly apart from the dim tool/think process. Tests: `tests/webui-assets.test.ts`.
19
+ - **Success-claim no longer vetoed by plan-bookkeeping failures** (`src/modules/hallucination/success-claim.ts`): `checkSuccessClaim` ignores `plan`/`plan_step`/`todo` evidences — their failures police the agent's own notebook (covered by the final audit), never task outcome. Lived-in case (ses_mupgenhq_75f66l, qwen3.5-9b): an honest "tests passed" was retracted over a stale `re-plan` error ("Длина checks должна совпадать"), forcing a same-substance re-answer after the task was done. Tests: `tests/hallucination-success-claim.test.ts`.
20
+
21
+ - **Web UI boot was dead on 2.15.0** (`src/modules/webui/assets/js/settings.js`): a stray `}` in `setStaleBar` pushed `return` to top level (`Uncaught SyntaxError: Illegal return statement`), killing the whole `app.js` import graph — blank page, no feed, selectors stuck at `–`. One-line brace fix.
22
+ - **Web e2e boot smoke** (`tests/e2e/web-boot.e2e-test.ts`): `mma web` boots in a real Chromium with zero console/page errors (live session tab, chrome skeleton, settings modal) and every served JS module in the live `/app.js` import graph answers 200; `web-helpers.launch` now names collected console errors when the feed never boots, so the next broken module fails with its `SyntaxError` in the message instead of a bare timeout.
23
+
24
+ ## [2.16.0] - 2026-10-01
25
+
26
+ ### Added
27
+
28
+ - **Web dropdown search for models and sessions** (`src/modules/webui/assets/js/settings.js`, `tabs.js`, `format.js`, `index.html`, `styles.css`, `src/i18n/en.json`, `ru.json`): the composer model menu and the sessions dropdown (`☰`) gain a sticky search field — case-insensitive substring (`matchesQuery` in `format.js`; sessions match name + id) with keyboard support (`↓` into the list, `Enter` picks the first hit, `Esc` clears/closes, `↑` back to search). The sessions input lives statically in the markup so the 2s state poll never steals the caret; the model filter survives list re-reads and clears on pick. Empty sections hide with their headers, zero hits show `no matches`; `↻ refresh` / `+ new session` stay outside the filter. New keys `web.selectors.search_models` / `web.sessions.search` (en+ru). Tests: `tests/webui-assets.test.ts`.
29
+
9
30
  ## [2.15.0] - 2026-10-01
10
31
 
11
32
  ### Added
@@ -23,6 +23,7 @@ initRail, setPlan as railPlan, railEvent, applyRailI18n,
23
23
  import {
24
24
  initSidebar, sidebarSessionChanged, sidebarMapState,
25
25
  sidebarTraceEvent, sidebarTurnEnd, sidebarBusy, sidebarMapIndicator,
26
+ sidebarPullLog,
26
27
  applySidebarI18n,
27
28
  } from "./js/sidebar.js";
28
29
 
@@ -141,7 +142,7 @@ const handlers = [
141
142
  ["tool_end", onToolEnd],
142
143
  ["plan", onPlan],
143
144
  ["question", onQuestion],
144
- ["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(); sidebarBusy(false); }],
145
+ ["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(d.turnSeq); sidebarBusy(false); sidebarPullLog(); }],
145
146
  ["log", onLog],
146
147
  ["retract", (d) => onRetract(d.reason || "", d.sessionId)],
147
148
  ];
@@ -161,7 +162,10 @@ for (const ev of ["tool_start", "tool_end", "text", "plan"]) {
161
162
  });
162
163
  }
163
164
  // Sidebar Trace: live per-session event log. Feed handlers stay untouched.
164
- for (const ev of ["tool_start", "tool_end", "phase", "plan", "question"]) {
165
+ // Tool/text/phase/plan/question/error stream live; status/log/retract/index
166
+ // ride the same SSE bus; critic/MoE/compaction/probe/usage/audit/claim rows
167
+ // arrive via the session-log supplement Trace pulls on every turn_end.
168
+ for (const ev of ["tool_start", "tool_end", "phase", "plan", "question", "meta", "log", "retract", "index"]) {
165
169
  es.addEventListener(ev, (e) => { const d = parse(e); if (d) sidebarTraceEvent(ev, d); });
166
170
  }
167
171
  es.addEventListener("text", (e) => { const d = parse(e); if (d) sidebarTraceEvent("text", d); });
@@ -16,6 +16,7 @@
16
16
  <div class="dd" id="sessDD">
17
17
  <button class="icon-btn" id="sessBtn" title="all sessions" aria-label="all sessions" aria-haspopup="menu" aria-expanded="false">☰</button>
18
18
  <div class="dd-menu">
19
+ <input class="searchbox" id="sessSearch" type="search" autocomplete="off" spellcheck="false" placeholder="search sessions…" aria-label="search sessions">
19
20
  <div class="h" id="sessActiveHeader">active</div>
20
21
  <div id="sessActiveList"></div>
21
22
  <div class="h" id="sessRecentHeader">recent</div>
@@ -444,44 +444,23 @@ function safeUrl(url) {
444
444
  return null;
445
445
  }
446
446
 
447
- function maybeGroupFilerow(feed, row) {
448
- const prev = row.previousElementSibling;
449
- const group = prev && prev.classList && prev.classList.contains("filegroup") ? prev : null;
450
- const prevRow = prev && prev.classList && prev.classList.contains("filerow") ? prev : null;
451
- if (group && group.dataset.open !== "1") {
452
- group.querySelector(".items").appendChild(row);
453
- if (row.dataset.fail === "1") group.dataset.fail = "1";
454
- const n = group.querySelectorAll(".items > .filerow").length;
455
- group.querySelector(".count").textContent = pluralCount("web.card.files", n);
456
- group.querySelector(".ok").textContent = group.dataset.fail === "1" ? " ✗" : " ✓";
457
- return group;
458
- }
459
- if (prevRow) {
460
- const wrap = el("div", "filegroup");
461
- const head = el("button", "head");
462
- head.setAttribute("aria-expanded", "false");
463
- head.appendChild(el("span", "g", "│ "));
464
- head.appendChild(el("span", "count", pluralCount("web.card.files", 2)));
465
- const fail = prevRow.dataset.fail === "1" || row.dataset.fail === "1";
466
- if (fail) wrap.dataset.fail = "1";
467
- head.appendChild(el("span", "ok", fail ? " ✗" : " ✓"));
468
- const chev = icon("chevron-down");
469
- head.appendChild(chev);
470
- const items = el("div", "items hidden");
471
- feed.replaceChild(wrap, prevRow);
472
- items.appendChild(prevRow);
473
- items.appendChild(row);
474
- wrap.appendChild(head);
475
- wrap.appendChild(items);
476
- head.addEventListener("click", () => {
477
- const open = wrap.dataset.open === "1";
478
- wrap.dataset.open = open ? "" : "1";
479
- head.setAttribute("aria-expanded", open ? "false" : "true");
480
- items.classList.toggle("hidden", open);
481
- });
482
- return wrap;
447
+ // File rows are always expanded: one `filerow` per file-tool call, no
448
+ // collapsing group. Consecutive rows stay a plain expanded list (the old
449
+ // E grouping with a chevron toggle hid the list and failed to open).
450
+ function displayPathFromRoot(path) {
451
+ const raw = String(path == null ? "" : path).replace(/\\/g, "/");
452
+ try {
453
+ const ws = S && S.state && S.state.host && S.state.host.workspace
454
+ ? String(S.state.host.workspace).replace(/\\/g, "/").replace(/\/+$/, "")
455
+ : "";
456
+ if (ws && (raw === ws || raw.indexOf(ws + "/") === 0)) {
457
+ const rel = raw === ws ? "" : raw.slice(ws.length + 1);
458
+ if (rel) return rel;
459
+ }
460
+ } catch (err) {
461
+ /* fail-open: show raw path */
483
462
  }
484
- return null;
463
+ return raw;
485
464
  }
486
465
 
487
466
  function cardFor(name, args, sid, ok) {
@@ -490,22 +469,21 @@ function cardFor(name, args, sid, ok) {
490
469
  if (FILE_TOOLS.indexOf(name) !== -1) {
491
470
  const path = args.path || args.file || args.dir;
492
471
  if (!path) return null;
472
+ const shown = displayPathFromRoot(path);
493
473
  const row = el("div", "filerow " + categoryOf(name));
494
474
  row.appendChild(el("span", "g", "◇"));
495
475
  row.appendChild(el("span", "lab", labelOf(name)));
496
476
  row.appendChild(el("span", "sep", " · "));
497
477
  if (OPENABLE_TOOLS.indexOf(name) !== -1) {
498
- const openLink = el("a", "path", String(path).split(/[\\/]/).pop() || path);
499
- openLink.href = "/api/file?path=" + encodeURIComponent(path);
478
+ const openLink = el("a", "path", shown || String(path));
479
+ openLink.href = "/api/file?path=" + encodeURIComponent(String(path));
500
480
  openLink.target = "_blank";
501
481
  openLink.rel = "noopener noreferrer";
502
- openLink.title = t("web.card.open_title") + " · " + path;
482
+ openLink.title = t("web.card.open_title") + " · " + String(path);
503
483
  row.appendChild(openLink);
504
- const tail = String(path).split(/[\\/]/).slice(0, -1).join("/");
505
- if (tail) row.appendChild(el("span", "tail", " · " + tail));
506
484
  } else {
507
- const s = el("span", "tail", path);
508
- s.title = path;
485
+ const s = el("span", "tail", shown || String(path));
486
+ s.title = String(path);
509
487
  row.appendChild(s);
510
488
  }
511
489
  const copyBtn = el("button", "copy-btn");
@@ -514,7 +492,7 @@ function cardFor(name, args, sid, ok) {
514
492
  copyBtn.setAttribute("aria-label", t("web.card.copy_title"));
515
493
  copyBtn.appendChild(icon("copy"));
516
494
  copyBtn.addEventListener("click", () => {
517
- copyText(path).then((ok) => {
495
+ copyText(String(path)).then((ok) => {
518
496
  copyBtn.replaceChildren(icon(ok ? "check" : "copy"));
519
497
  if (!ok) { const st = row.querySelector(".st"); if (st) st.textContent = " ⚠"; }
520
498
  window.setTimeout(() => copyBtn.replaceChildren(icon("copy")), 1400);
@@ -523,11 +501,10 @@ function cardFor(name, args, sid, ok) {
523
501
  row.appendChild(copyBtn);
524
502
  row.appendChild(el("span", "st", ok === false ? " ✗" : ok === true ? " ✓" : ""));
525
503
  if (ok === false) row.dataset.fail = "1";
526
- if (/\.drawio(\.xml)?$/i.test(path) && verbosity() === "verbose") {
504
+ if (/\.drawio(\.xml)?$/i.test(String(path)) && verbosity() === "verbose") {
527
505
  row.appendChild(el("span", "detail", "↳ " + t("web.card.drawio_hint")));
528
506
  }
529
507
  appendTo(feed, row);
530
- try { maybeGroupFilerow(feed, row); } catch (err) { /* fail-open: plain row stays */ }
531
508
  return row;
532
509
  }
533
510
  if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
@@ -90,6 +90,14 @@ for (const raw of text.split("\n")) {
90
90
  return "";
91
91
  }
92
92
 
93
+ // Case-insensitive substring match for dropdown filters (model / session
94
+ // search). Empty query matches everything; non-strings stringify safely.
95
+ export function matchesQuery(haystack, q) {
96
+ const needle = String(q == null ? "" : q).trim().toLowerCase();
97
+ if (!needle) return true;
98
+ return String(haystack == null ? "" : haystack).toLowerCase().indexOf(needle) !== -1;
99
+ }
100
+
93
101
  export function relTime(ts) {
94
102
  if (!ts) return "";
95
103
  const diff = Date.now() - new Date(ts).getTime();
@@ -8,7 +8,7 @@ el, icon, link, inlineTextNodes, connStatus, staleBar, makeToggle,
8
8
  import { api } from "./api.js";
9
9
  import { t, loadI18n } from "./i18n.js";
10
10
  import { S, setPath, getPath, THEME_KEY, hooks } from "./store.js";
11
- import { contextLabel } from "./format.js";
11
+ import { contextLabel, matchesQuery } from "./format.js";
12
12
  import { closeAllMenus, setSessOpen } from "./tabs.js";
13
13
  import { setIndicator, closeConfirm, confirmOverlay, trapTab } from "./dialog.js";
14
14
  import { controlFor, PANEL_SECTIONS, keyToPanel } from "./settings-controls.js";
@@ -48,6 +48,11 @@ function menuAction(label, iconName, onClick) {
48
48
  return mi;
49
49
  }
50
50
 
51
+ // Live filter text of the composer model dropdown. Kept module-level so a
52
+ // `renderSelectors()` re-read (config/models reload) preserves the query and
53
+ // the input is rebuilt with it; cleared once a model is actually picked.
54
+ let modelFilter = "";
55
+
51
56
  export function renderSelectors() {
52
57
  const cfg = S.state.config || {};
53
58
  const provider = cfg.provider || {};
@@ -55,13 +60,70 @@ export function renderSelectors() {
55
60
  setSelButton(document.getElementById("selModelBtn"), t("web.selectors.model"), cfg.model || "—");
56
61
  const modelMenu = document.getElementById("selModelMenu");
57
62
  modelMenu.textContent = "";
58
- if (!S.state.models.length) {
59
- modelMenu.appendChild(el("div", "mi dim", t("web.selectors.no_models")));
60
- } else {
61
- S.state.models.forEach((m) => {
62
- modelMenu.appendChild(menuItem(m, "", m === cfg.model, () => postConfig("model", m, true)));
63
+ // Search row stays outside the filtered list: `drawModelList` only touches
64
+ // `listBox`, so typing never rebuilds (or unfocuses) the input itself.
65
+ const search = document.createElement("input");
66
+ search.id = "selModelSearch";
67
+ search.className = "searchbox";
68
+ search.type = "search";
69
+ search.autocomplete = "off";
70
+ search.spellcheck = false;
71
+ search.placeholder = t("web.selectors.search_models");
72
+ search.setAttribute("aria-label", t("web.selectors.search_models"));
73
+ search.value = modelFilter;
74
+ search.addEventListener("click", (e) => e.stopPropagation());
75
+ modelMenu.appendChild(search);
76
+ const listBox = el("div", "model-menu-list");
77
+ modelMenu.appendChild(listBox);
78
+ const drawModelList = () => {
79
+ listBox.textContent = "";
80
+ const models = S.state.models.filter((m) => matchesQuery(m, modelFilter));
81
+ if (!models.length) {
82
+ listBox.appendChild(el("div", "mi dim", S.state.models.length ? t("web.settings.no_matches") : t("web.selectors.no_models")));
83
+ return;
84
+ }
85
+ models.forEach((m) => {
86
+ const item = menuItem(m, "", m === cfg.model, () => {
87
+ modelFilter = "";
88
+ postConfig("model", m, true);
89
+ });
90
+ // ArrowUp on the first row returns focus to the search field.
91
+ item.addEventListener("keydown", (e) => {
92
+ if (e.key === "ArrowUp" && item === listBox.querySelector(".mi:not(.dim)")) {
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ search.focus();
96
+ }
97
+ });
98
+ listBox.appendChild(item);
63
99
  });
64
- }
100
+ };
101
+ search.addEventListener("input", () => {
102
+ modelFilter = search.value;
103
+ drawModelList();
104
+ });
105
+ search.addEventListener("keydown", (e) => {
106
+ e.stopPropagation();
107
+ if (e.key === "ArrowDown") {
108
+ e.preventDefault();
109
+ const first = listBox.querySelector(".mi:not(.dim)");
110
+ if (first) first.focus();
111
+ } else if (e.key === "Enter") {
112
+ e.preventDefault();
113
+ const first = listBox.querySelector(".mi:not(.dim)");
114
+ if (first) first.click();
115
+ } else if (e.key === "Escape") {
116
+ if (search.value) {
117
+ e.preventDefault();
118
+ search.value = "";
119
+ modelFilter = "";
120
+ drawModelList();
121
+ } else {
122
+ closeAllMenus();
123
+ }
124
+ }
125
+ });
126
+ drawModelList();
65
127
  modelMenu.appendChild(el("div", "sep"));
66
128
  modelMenu.appendChild(
67
129
  menuAction(t("web.selectors.refresh_list"), "refresh", () => {
@@ -196,6 +258,11 @@ document.querySelectorAll("[data-sel]").forEach((sel) => {
196
258
  });
197
259
  const open = sel.classList.toggle("open");
198
260
  btn.setAttribute("aria-expanded", open ? "true" : "false");
261
+ // Opening the model menu lands focus in its search field.
262
+ if (open && sel.dataset && sel.dataset.sel === "model") {
263
+ const search = document.getElementById("selModelSearch");
264
+ if (search) setTimeout(() => search.focus(), 0);
265
+ }
199
266
  });
200
267
  });
201
268
 
@@ -340,7 +407,6 @@ export function setStaleBar(stale, force) {
340
407
  if (!on) return;
341
408
  staleBar.appendChild(el("span", "", "⚠ " + t("web.i18n.stale") + " "));
342
409
  staleBar.appendChild(link(t("web.i18n.reload"), "", () => location.reload()));
343
- }
344
410
  return lastStale;
345
411
  }
346
412
 
@@ -25,6 +25,8 @@ let trace = []; // { id, kind, ts, name, args, output, success, duratio
25
25
  let selected = null; // trace id
26
26
  let turn = 0;
27
27
  let seq = 0;
28
+ let logCursor = null; // max session-log ts already pulled (supplement fetch)
29
+ let logPulling = false;
28
30
  const nodeById = new Map(); // entry id → row node
29
31
  const callIndex = new Map(); // toolCallId → entry
30
32
  const headerByTurn = new Map(); // turn → header node
@@ -48,6 +50,7 @@ export function setSidebarTab(id, persist = true) {
48
50
  if (!TABS.includes(id)) id = "session";
49
51
  if (persist) storeTab(id);
50
52
  const effective = id === "trace" && mqMobile() ? "session" : id;
53
+ if (effective !== active) collapseDetail();
51
54
  active = effective;
52
55
  TABS.forEach((name) => {
53
56
  const pane = paneEl(name);
@@ -90,6 +93,18 @@ function rowLabel(entry) {
90
93
  case "plan": return t("web.sidebar.trace_plan");
91
94
  case "question": return t("web.sidebar.trace_question");
92
95
  case "text": return t("web.sidebar.trace_text", { count: entry.chars || 0 });
96
+ case "status": return t("web.sidebar.trace_status");
97
+ case "log": return t("web.sidebar.trace_log", { level: entry.level || "" });
98
+ case "retract": return t("web.sidebar.trace_retract");
99
+ case "index": return t("web.sidebar.trace_index", { phase: entry.phase || "" });
100
+ case "critic": return t("web.sidebar.trace_critic", { status: entry.status || "" });
101
+ case "moe": return t("web.sidebar.trace_moe");
102
+ case "compaction": return t("web.sidebar.trace_compaction");
103
+ case "probe": return t("web.sidebar.trace_probe");
104
+ case "usage": return t("web.sidebar.trace_usage");
105
+ case "audit": return t("web.sidebar.trace_audit");
106
+ case "claim": return t("web.sidebar.trace_claim", { kind: entry.subkind || "" });
107
+ case "output": return t("web.sidebar.trace_output");
93
108
  default: return labelOf(entry.name);
94
109
  }
95
110
  }
@@ -103,11 +118,19 @@ function rowArgs(entry) {
103
118
  return t("web.rail.step_of", { cur: Math.min(idx + 1, total), total });
104
119
  }
105
120
  if (entry.kind === "question") return entry.text || "";
121
+ // Диагностические строки несут смысл в одной строке (статус/лог/ретракт/
122
+ // индекс/критик/MoE/компакция/проба/usage/аудит/claim/вывод).
123
+ if (entry.text && typeof entry.text === "string" && entry.kind !== "tool" && entry.kind !== "tool_fail") {
124
+ const line = entry.text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
125
+ return line.slice(0, 160);
126
+ }
106
127
  return argText(entry.args);
107
128
  }
108
129
 
109
130
  function isWarn(entry) {
110
- return entry.kind === "tool_fail" || entry.kind === "error";
131
+ return entry.kind === "tool_fail" || entry.kind === "error" || entry.kind === "claim" ||
132
+ (entry.kind === "critic" && entry.status === "critical") ||
133
+ (entry.kind === "log" && (entry.level === "warn" || entry.level === "error"));
111
134
  }
112
135
 
113
136
  function renderRow(entry) {
@@ -221,12 +244,16 @@ export function sidebarSessionChanged(sid) {
221
244
  textEntry = null;
222
245
  turn = 0;
223
246
  seq = 0;
247
+ // Trace живой: старые ходы чужой сессии не докачиваем, только новые события.
248
+ logCursor = new Date().toISOString();
249
+ logPulling = false;
224
250
  collapseDetail();
225
251
  renderList();
226
252
  }
227
253
 
228
- export function sidebarTurnEnd() {
229
- turn += 1;
254
+ export function sidebarTurnEnd(turnSeq) {
255
+ // Серверный номер хода (реконнекты локальный счётчик не сбивают).
256
+ turn = typeof turnSeq === "number" && turnSeq > turn ? turnSeq : turn + 1;
230
257
  textEntry = null;
231
258
  }
232
259
 
@@ -276,13 +303,118 @@ export function sidebarTraceEvent(kind, data) {
276
303
  const chunk = data.chunk || "";
277
304
  textEntry.chars += chunk.length;
278
305
  textEntry.text += chunk;
279
- if (textEntry.text.length > 4000) textEntry.text = textEntry.text.slice(-4000);
306
+ // Полный текст хода — в память (деталь покажет целиком); обрезка честная,
307
+ // тем же капом и маркером, что у выводов тулов.
308
+ if (textEntry.text.length > MAX_OUTPUT_CHARS) {
309
+ textEntry.text = textEntry.text.slice(0, MAX_OUTPUT_CHARS) +
310
+ "\n… [truncated from " + textEntry.chars + " chars]";
311
+ }
280
312
  updateTextRow(textEntry);
281
313
  break;
282
314
  }
283
315
  case "error":
284
316
  pushRow(append({ kind: "error", message: data.message || data.error || "" }));
285
317
  break;
318
+ case "meta": {
319
+ // Тела тулов (`kind: "tool"`) уже идут строками tool_start/tool_end —
320
+ // в Trace несём только статусы (stuck/escalation/honest-stop и т.п.).
321
+ if (data.kind !== undefined && data.kind !== "status") break;
322
+ const text = data.chunk || "";
323
+ if (!text.trim()) break;
324
+ pushRow(append({ kind: "status", text }));
325
+ break;
326
+ }
327
+ case "log": {
328
+ // Внутренний шёпот петли (`diagnostic` + info) в Trace не несём —
329
+ // только варнинги/ошибки и пользовательские info (probe/overflow).
330
+ if (data.diagnostic && data.level === "info") break;
331
+ const text = data.text || "";
332
+ if (!text.trim()) break;
333
+ pushRow(append({ kind: "log", text, level: data.level || "", source: data.source || "" }));
334
+ break;
335
+ }
336
+ case "retract":
337
+ pushRow(append({ kind: "retract", text: data.reason || "" }));
338
+ break;
339
+ case "index": {
340
+ const stats = data.stats && typeof data.stats === "object"
341
+ ? Object.entries(data.stats).map(([k, v]) => k + ": " + v).join(", ")
342
+ : "";
343
+ pushRow(append({ kind: "index", phase: data.phase || "", text: stats }));
344
+ break;
345
+ }
346
+ default:
347
+ break;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Докачка диагностических событий из session-лога (критик, MoE, компакция,
353
+ * проба контекста, usage, аудит, claim-retry, вывод). Отдельных SSE-продюсеров
354
+ * нет — данные уже лежат в логе с `iteration`; тянем инкремент по курсору на
355
+ * каждый `turn_end`. Живые tool/text-строки не дублируются (другие kinds).
356
+ */
357
+ export async function sidebarPullLog() {
358
+ const sid = sbEl && sbEl.dataset.sid;
359
+ if (!sid || logPulling) return;
360
+ logPulling = true;
361
+ try {
362
+ let url = "/api/sessions/" + encodeURIComponent(sid) + "/log?limit=100";
363
+ if (logCursor) url += "&since=" + encodeURIComponent(logCursor);
364
+ const r = await api(url);
365
+ if (!r.ok || !r.data || !Array.isArray(r.data.log)) return;
366
+ for (const e of r.data.log) {
367
+ if (typeof e.ts === "string" && (logCursor === null || e.ts > logCursor)) logCursor = e.ts;
368
+ appendLogEntry(e);
369
+ }
370
+ } catch {
371
+ /* best-effort supplement: Trace живёт и без докачки */
372
+ } finally {
373
+ logPulling = false;
374
+ }
375
+ }
376
+
377
+ function appendLogEntry(e) {
378
+ if (!e || typeof e.type !== "string") return;
379
+ switch (e.type) {
380
+ case "critic_check":
381
+ pushRow(append({ kind: "critic", status: "check", text: "", iteration: e.iteration }));
382
+ break;
383
+ case "critic_verdict":
384
+ pushRow(append({ kind: "critic", status: e.status || "", text: e.content || "", iteration: e.iteration }));
385
+ break;
386
+ case "critic_escalation":
387
+ pushRow(append({ kind: "critic", status: "escalation", text: e.content || "", iteration: e.iteration }));
388
+ break;
389
+ case "moe_plan":
390
+ case "moe_subtask":
391
+ case "moe_replan":
392
+ case "moe_verify": {
393
+ const bits = [e.title, e.subtaskId, e.expertTag, e.status].filter((x) => typeof x === "string" && x).join(" · ");
394
+ pushRow(append({ kind: "moe", subtype: e.type, text: bits, iteration: e.iteration }));
395
+ break;
396
+ }
397
+ case "compaction":
398
+ pushRow(append({ kind: "compaction", text: e.content || "", iteration: e.iteration }));
399
+ break;
400
+ case "context_probe":
401
+ pushRow(append({ kind: "probe", text: e.content || "" }));
402
+ break;
403
+ case "llm_usage": {
404
+ const toks = typeof e.totalTokens === "number" ? e.totalTokens : (e.promptTokens || 0) + (e.completionTokens || 0);
405
+ const who = [e.model || e.provider, toks ? toks + " tok" : ""].filter(Boolean).join(" · ");
406
+ pushRow(append({ kind: "usage", text: who, iteration: e.iteration }));
407
+ break;
408
+ }
409
+ case "audit":
410
+ pushRow(append({ kind: "audit", text: e.content || "", iteration: e.iteration }));
411
+ break;
412
+ case "claim_retry":
413
+ pushRow(append({ kind: "claim", subkind: e.kind || "", text: e.content || "", iteration: e.iteration }));
414
+ break;
415
+ case "output":
416
+ pushRow(append({ kind: "output", text: e.content || "", iteration: e.iteration }));
417
+ break;
286
418
  default:
287
419
  break;
288
420
  }
@@ -331,6 +463,8 @@ function renderDetail(entry) {
331
463
  if (d) meta.appendChild(kv(t("web.sidebar.detail_time"), d.toLocaleTimeString()));
332
464
  if (entry.toolCallId) meta.appendChild(kv(t("web.sidebar.detail_tool_call"), String(entry.toolCallId)));
333
465
  if (sbEl && sbEl.dataset.sid) meta.appendChild(kv(t("web.sidebar.detail_session"), sbEl.dataset.sid));
466
+ meta.appendChild(kv(t("web.sidebar.detail_turn"), String(entry.turn)));
467
+ if (typeof entry.iteration === "number") meta.appendChild(kv(t("web.sidebar.detail_iteration"), String(entry.iteration)));
334
468
  detailBody.appendChild(meta);
335
469
 
336
470
  if (entry.kind === "tool" || entry.kind === "tool_fail") {
@@ -338,6 +472,7 @@ function renderDetail(entry) {
338
472
  detailBody.appendChild(detailGroup(t("web.sidebar.detail_output"), codeBlock(capOutput(entry.output))));
339
473
  } else if (entry.kind === "text") {
340
474
  detailBody.appendChild(detailGroup(t("web.sidebar.detail_text"), codeBlock(entry.text || "")));
475
+ loadTextProvenance(entry);
341
476
  } else if (entry.kind === "plan") {
342
477
  const list = el("div");
343
478
  (entry.steps || []).forEach((s) => {
@@ -350,6 +485,52 @@ function renderDetail(entry) {
350
485
  detailBody.appendChild(detailGroup(t("web.sidebar.trace_question"), codeBlock(entry.text || "")));
351
486
  } else if (entry.kind === "error") {
352
487
  detailBody.appendChild(detailGroup(t("web.sidebar.detail_error"), codeBlock(entry.message || "")));
488
+ } else if (
489
+ entry.kind === "status" || entry.kind === "log" || entry.kind === "retract" ||
490
+ entry.kind === "index" || entry.kind === "critic" || entry.kind === "moe" ||
491
+ entry.kind === "compaction" || entry.kind === "probe" || entry.kind === "usage" ||
492
+ entry.kind === "audit" || entry.kind === "claim" || entry.kind === "output"
493
+ ) {
494
+ const title =
495
+ entry.kind === "moe" && entry.subtype ? entry.subtype
496
+ : entry.kind === "log" && entry.level ? "log · " + entry.level
497
+ : entry.kind === "index" && entry.phase ? "index · " + entry.phase
498
+ : entry.kind === "critic" && entry.status ? "critic · " + entry.status
499
+ : entry.kind === "claim" && entry.subkind ? "claim_retry · " + entry.subkind
500
+ : rowLabel(entry);
501
+ detailBody.appendChild(detailGroup(title, codeBlock(entry.text || "")));
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Привязка текста хода к session-логу: сколько assistant-сообщений в сессии,
507
+ * их итерации и провенанс последнего LLM-вызова (модель/провайдер/токены).
508
+ * Живой текст хода уже показан выше целиком; это — сверка с логом.
509
+ */
510
+ async function loadTextProvenance(entry) {
511
+ const sid = sbEl && sbEl.dataset.sid;
512
+ if (!sid || !detailBody || selected !== entry.id) return;
513
+ try {
514
+ const r = await api(
515
+ "/api/sessions/" + encodeURIComponent(sid) + "/log?types=assistant,llm_usage&limit=20",
516
+ );
517
+ if (!r.ok || !r.data || !Array.isArray(r.data.log) || selected !== entry.id) return;
518
+ const assistants = r.data.log.filter((e) => e.type === "assistant");
519
+ const usages = r.data.log.filter((e) => e.type === "llm_usage");
520
+ const iters = assistants.map((e) => e.iteration).filter((n) => typeof n === "number");
521
+ const last = usages.length > 0 ? usages[usages.length - 1] : null;
522
+ const bits = [
523
+ t("web.sidebar.detail_provenance_count", { n: assistants.length }) +
524
+ (iters.length > 0 ? " · iter " + Math.min(...iters) + "–" + Math.max(...iters) : ""),
525
+ last
526
+ ? [last.model || last.provider, last.totalTokens ?? (last.promptTokens || 0) + (last.completionTokens || 0)]
527
+ .filter(Boolean)
528
+ .join(" · ")
529
+ : "",
530
+ ].filter(Boolean).join("\n");
531
+ if (bits) detailBody.appendChild(detailGroup(t("web.sidebar.detail_provenance"), codeBlock(bits)));
532
+ } catch {
533
+ /* best-effort: живой текст уже на месте */
353
534
  }
354
535
  }
355
536
 
@@ -374,6 +555,18 @@ function mapKv(label, value, cls) {
374
555
  return row;
375
556
  }
376
557
 
558
+ // Корень — всегда длинный путь: подпись сверху, путь снизу на всю ширину.
559
+ // В одну flex-строку с подписью он не влезает и склеивается с ней.
560
+ function mapPathRow(label, value) {
561
+ const wrap = el("div", "map-path");
562
+ wrap.appendChild(el("span", "dim", label));
563
+ const b = el("b");
564
+ b.textContent = value;
565
+ b.title = value;
566
+ wrap.appendChild(b);
567
+ return wrap;
568
+ }
569
+
377
570
  let reindexing = false; // клиентский спиннер до свежего снапшота
378
571
  let postResolved = false; // POST вернулся; следующий снапшот снимет спиннер
379
572
  let reindexError = false; // последний POST не удался
@@ -395,7 +588,7 @@ export function sidebarMapState(host) {
395
588
  mapEl.appendChild(mapKv(t("web.sidebar.map_files"), num(map.files)));
396
589
  mapEl.appendChild(mapKv(t("web.sidebar.map_symbols"), num(map.symbols)));
397
590
  mapEl.appendChild(mapKv(t("web.sidebar.map_tokens"), map.indexed ? fmtTokens(map.tokens || 0) : "—"));
398
- if (host && host.workspace) mapEl.appendChild(mapKv(t("web.sidebar.map_root"), host.workspace));
591
+ if (host && host.workspace) mapEl.appendChild(mapPathRow(t("web.sidebar.map_root"), host.workspace));
399
592
 
400
593
  if (Array.isArray(map.languages) && map.languages.length > 0) {
401
594
  const head = el("div", "kv");