rterm-backend 3.8.2 → 3.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/gybackend.cjs +362 -52
  2. package/package.json +1 -1
package/bin/gybackend.cjs CHANGED
@@ -258480,6 +258480,9 @@ var HistorySqliteStore_exports = {};
258480
258480
  __export(HistorySqliteStore_exports, {
258481
258481
  HistorySqliteStore: () => HistorySqliteStore
258482
258482
  });
258483
+ function digestFor(message) {
258484
+ return `${message.type}\0${message.dataJson ?? ""}`;
258485
+ }
258483
258486
  var import_node_fs5, import_node_path8, HistorySqliteStore;
258484
258487
  var init_HistorySqliteStore = __esm({
258485
258488
  "../../packages/backend/src/services/history/HistorySqliteStore.ts"() {
@@ -258651,14 +258654,153 @@ var init_HistorySqliteStore = __esm({
258651
258654
  lastProfileMaxTokens: row.last_profile_max_tokens ?? void 0
258652
258655
  }));
258653
258656
  }
258657
+ /**
258658
+ * Every session WITH all messages.
258659
+ *
258660
+ * FREEZE WARNING (v3.8.4): this JSON.parses every message of every session
258661
+ * in one synchronous burst — on this machine's 1.6 GB / multi-session store
258662
+ * that is seconds of blocked event loop (so the UI freezes, because
258663
+ * better-sqlite3 is synchronous and runs on the same thread).
258664
+ *
258665
+ * It is only acceptable for genuinely whole-store work. Callers that merely
258666
+ * need session *lists* must use `listChatSessionSummaries()` (COUNT only, no
258667
+ * message bodies) — see `searchChatHistoryBounded` in historySearch.ts,
258668
+ * which replaced the old bridge call to this method.
258669
+ */
258654
258670
  listChatSessions() {
258655
258671
  return this.listChatSessionSummaries().map((summary) => this.loadChatSession(summary.id)).filter(
258656
258672
  (session) => session !== null
258657
258673
  );
258658
258674
  }
258675
+ /**
258676
+ * Session meta WITHOUT messages.
258677
+ *
258678
+ * Two fixed-cost hot-path bugs this closes:
258679
+ *
258680
+ * 1. `ChatHistoryService.saveSession` called `loadChatSession()` purely to
258681
+ * learn `createdAt`. That JSON.parse'd the ENTIRE session — measured
258682
+ * ~117 MB / ~1.5 s of parse for a 6k-message session — and threw every
258683
+ * parsed message away, on every save.
258684
+ * 2. `AgentService_v2.trySaveSessionFromCheckpoint` called `loadSession()`
258685
+ * for a default it never used, because `updateSessionFromMessages()`
258686
+ * rebuilds `session.messages` from scratch. Dropping that call outright
258687
+ * would have reset the session TITLE to "New Session" on every restore,
258688
+ * so the title is read here too.
258689
+ */
258690
+ getChatSessionMeta(sessionId) {
258691
+ const row = this.db.prepare("SELECT created_at, title FROM chat_sessions WHERE id = ?").get(sessionId);
258692
+ return row ? { createdAt: row.created_at, title: row.title } : null;
258693
+ }
258694
+ /** Created-at scalar only — a single indexed row, never a message parse. */
258695
+ getChatSessionCreatedAt(sessionId) {
258696
+ return this.getChatSessionMeta(sessionId)?.createdAt;
258697
+ }
258698
+ /**
258699
+ * Existing message bodies as RAW TEXT, ordered by position, WITHOUT parsing.
258700
+ *
258701
+ * The raw `message_data_json` is needed for a byte-exact comparison against
258702
+ * the newly serialized body. Deliberately NOT JSON.parse'd: the parse is the
258703
+ * expensive half of the freeze (~1.5 s / 117 MB measured) and is unnecessary
258704
+ * for an equality test.
258705
+ *
258706
+ * (An earlier revision compared only `substr(json, 1, 200)`. That was WRONG:
258707
+ * a message whose content grows past the first 200 characters — exactly what
258708
+ * streaming does to the last AI message — leaves the prefix untouched, so the
258709
+ * row would be skipped and the persisted history would silently stay STALE.
258710
+ * Full-text comparison is what makes "unchanged => skip" safe.)
258711
+ */
258712
+ loadSessionMessageState(sessionId) {
258713
+ const rows = this.db.prepare(
258714
+ `SELECT message_id, message_type, position, message_data_json
258715
+ FROM chat_session_messages
258716
+ WHERE session_id = ?
258717
+ ORDER BY position ASC`
258718
+ ).all(sessionId);
258719
+ return rows.map((row) => ({
258720
+ id: row.message_id,
258721
+ type: row.message_type,
258722
+ position: row.position,
258723
+ jsonText: row.message_data_json ?? ""
258724
+ }));
258725
+ }
258726
+ /**
258727
+ * Incremental write: touch only the rows that actually moved.
258728
+ *
258729
+ * The old path ran `DELETE ALL` + `INSERT ALL` on every save, rewriting the
258730
+ * whole session payload each time. Here, unchanged messages stay on disk
258731
+ * untouched; messages that are genuinely absent (compaction, rollback) are
258732
+ * removed individually.
258733
+ */
258734
+ applyChatSessionDelta(sessionId, desired, existingDigests, writeAll) {
258735
+ const desiredIds = /* @__PURE__ */ new Set();
258736
+ for (const m2 of desired) {
258737
+ desiredIds.add(m2.id);
258738
+ }
258739
+ const changed = writeAll ? desired : desired.filter((m2) => existingDigests.get(m2.id) !== digestFor(m2));
258740
+ const toRemove = [];
258741
+ for (const id of existingDigests.keys()) {
258742
+ if (!desiredIds.has(id)) {
258743
+ toRemove.push(id);
258744
+ }
258745
+ }
258746
+ if (changed.length === 0 && toRemove.length === 0) {
258747
+ return;
258748
+ }
258749
+ const upsert = this.db.prepare(
258750
+ `INSERT INTO chat_session_messages (
258751
+ session_id, position, message_id, message_type, message_data_json
258752
+ ) VALUES (?, ?, ?, ?, ?)
258753
+ ON CONFLICT(session_id, message_id) DO UPDATE SET
258754
+ position = excluded.position,
258755
+ message_type = excluded.message_type,
258756
+ message_data_json = excluded.message_data_json`
258757
+ );
258758
+ const removeOne = this.db.prepare(
258759
+ "DELETE FROM chat_session_messages WHERE session_id = ? AND message_id = ?"
258760
+ );
258761
+ this.db.transaction(() => {
258762
+ for (const id of toRemove) {
258763
+ removeOne.run(sessionId, id);
258764
+ }
258765
+ for (const m2 of changed) {
258766
+ upsert.run(sessionId, m2.position, m2.id, m2.type, m2.dataJson);
258767
+ }
258768
+ })();
258769
+ }
258770
+ /**
258771
+ * Full positional rewrite. Positions are `PRIMARY KEY (session_id,
258772
+ * position)`, so an in-place UPDATE cannot express a reorder without a
258773
+ * transient collision — the order guard in saveChatSession routes here when
258774
+ * (and only when) the surviving ids changed relative order.
258775
+ *
258776
+ * Therefore this really does DELETE + INSERT rather than upsert. An
258777
+ * `ON CONFLICT(session_id, message_id)` upsert looks like it would work but
258778
+ * does NOT: reassigning `position` collides on the (session_id, position)
258779
+ * PRIMARY KEY against a row that has not moved yet, and that conflict is not
258780
+ * absorbed by an ON CONFLICT clause targeting a different key —
258781
+ * SqliteError: UNIQUE constraint failed:
258782
+ * chat_session_messages.session_id, chat_session_messages.position
258783
+ * Clearing first (inside the same transaction) sidesteps the ordering
258784
+ * problem entirely. This path is rare by design, so the rewrite is cheap
258785
+ * enough, and it matches the old DELETE-ALL/INSERT-ALL semantics exactly.
258786
+ */
258787
+ replaceAllMessages(sessionId, desired) {
258788
+ const removeAll = this.db.prepare(
258789
+ "DELETE FROM chat_session_messages WHERE session_id = ?"
258790
+ );
258791
+ const insert = this.db.prepare(
258792
+ `INSERT INTO chat_session_messages (
258793
+ session_id, position, message_id, message_type, message_data_json
258794
+ ) VALUES (?, ?, ?, ?, ?)`
258795
+ );
258796
+ this.db.transaction(() => {
258797
+ removeAll.run(sessionId);
258798
+ desired.forEach((m2, index2) => {
258799
+ insert.run(sessionId, index2, m2.id, m2.type, m2.dataJson);
258800
+ });
258801
+ })();
258802
+ }
258659
258803
  saveChatSession(session) {
258660
- const existingCreatedAt = this.db.prepare("SELECT created_at FROM chat_sessions WHERE id = ?").get(session.id);
258661
- const createdAt = existingCreatedAt?.created_at ?? session.createdAt;
258662
258804
  const upsertSession = this.db.prepare(
258663
258805
  `INSERT INTO chat_sessions (
258664
258806
  id, title, last_checkpoint_offset, last_profile_max_tokens, created_at, updated_at
@@ -258671,34 +258813,45 @@ var init_HistorySqliteStore = __esm({
258671
258813
  last_profile_max_tokens = excluded.last_profile_max_tokens,
258672
258814
  updated_at = excluded.updated_at`
258673
258815
  );
258674
- const deleteMessages = this.db.prepare(
258675
- "DELETE FROM chat_session_messages WHERE session_id = ?"
258676
- );
258677
- const insertMessage = this.db.prepare(
258678
- `INSERT INTO chat_session_messages (
258679
- session_id, position, message_id, message_type, message_data_json
258680
- ) VALUES (?, ?, ?, ?, ?)`
258681
- );
258682
258816
  this.db.transaction(() => {
258683
258817
  upsertSession.run({
258684
258818
  id: session.id,
258685
258819
  title: session.title,
258686
258820
  lastCheckpointOffset: session.lastCheckpointOffset,
258687
258821
  lastProfileMaxTokens: session.lastProfileMaxTokens ?? null,
258688
- createdAt,
258822
+ createdAt: session.createdAt,
258689
258823
  updatedAt: session.updatedAt
258690
258824
  });
258691
- deleteMessages.run(session.id);
258692
- session.messages.forEach((message, index2) => {
258693
- insertMessage.run(
258694
- session.id,
258695
- index2,
258696
- message.id,
258697
- message.type,
258698
- JSON.stringify(message.data)
258699
- );
258700
- });
258701
258825
  })();
258826
+ const desired = session.messages.map(
258827
+ (message, index2) => ({
258828
+ id: message.id,
258829
+ type: message.type,
258830
+ position: index2,
258831
+ dataJson: JSON.stringify(message.data)
258832
+ })
258833
+ );
258834
+ const existingState = this.loadSessionMessageState(session.id);
258835
+ const existingDigests = /* @__PURE__ */ new Map();
258836
+ for (const row of existingState) {
258837
+ existingDigests.set(
258838
+ row.id,
258839
+ digestFor({ type: row.type, dataJson: row.jsonText })
258840
+ );
258841
+ }
258842
+ const storedOrder = existingState.map((row) => row.id);
258843
+ const desiredOrderOfStored = [];
258844
+ for (const m2 of desired) {
258845
+ if (existingDigests.has(m2.id)) {
258846
+ desiredOrderOfStored.push(m2.id);
258847
+ }
258848
+ }
258849
+ const reordered = storedOrder.length !== desiredOrderOfStored.length || storedOrder.some((id, i) => id !== desiredOrderOfStored[i]);
258850
+ if (reordered) {
258851
+ this.replaceAllMessages(session.id, desired);
258852
+ return;
258853
+ }
258854
+ this.applyChatSessionDelta(session.id, desired, existingDigests, false);
258702
258855
  }
258703
258856
  deleteChatSessions(sessionIds) {
258704
258857
  const ids = Array.from(
@@ -262553,7 +262706,8 @@ __export(historySearch_exports, {
262553
262706
  extractMessageText: () => extractMessageText2,
262554
262707
  findMatches: () => findMatches,
262555
262708
  normalizeMessages: () => normalizeMessages,
262556
- searchChatHistory: () => searchChatHistory
262709
+ searchChatHistory: () => searchChatHistory,
262710
+ searchChatHistoryBounded: () => searchChatHistoryBounded
262557
262711
  });
262558
262712
  function extractMessageText2(data) {
262559
262713
  if (!data || typeof data !== "object") return "";
@@ -262615,6 +262769,39 @@ function normalizeMessages(messages) {
262615
262769
  if (typeof messages === "object") return Object.values(messages);
262616
262770
  return [];
262617
262771
  }
262772
+ async function searchChatHistoryBounded(loadSummaries, loadSession, query, options = {}) {
262773
+ const trimmed = (query ?? "").trim();
262774
+ if (!trimmed) {
262775
+ return { query: trimmed, totalSessions: 0, totalMatches: 0, sessions: [], truncated: false };
262776
+ }
262777
+ const summaries = loadSummaries();
262778
+ const results = [];
262779
+ const yieldTick = () => new Promise((resolve2) => setTimeout(resolve2, 0));
262780
+ for (const summary of summaries) {
262781
+ const session = loadSession(summary.id);
262782
+ if (session) {
262783
+ const hit = searchChatHistory([session], trimmed, {
262784
+ ...options,
262785
+ // Per-session we want ALL snippets; the cap is applied per session by
262786
+ // searchChatHistory, and truncated is recomputed globally below.
262787
+ sessionLimit: 1
262788
+ });
262789
+ if (hit.sessions.length > 0) results.push(hit.sessions[0]);
262790
+ }
262791
+ await yieldTick();
262792
+ }
262793
+ results.sort((a, b) => b.matchCount - a.matchCount || b.updatedAt - a.updatedAt);
262794
+ const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
262795
+ const totalMatches = results.reduce((sum, s) => sum + s.matchCount, 0);
262796
+ const truncated = results.length > sessionLimit;
262797
+ return {
262798
+ query: trimmed,
262799
+ totalSessions: results.length,
262800
+ totalMatches,
262801
+ sessions: results.slice(0, sessionLimit),
262802
+ truncated
262803
+ };
262804
+ }
262618
262805
  function searchChatHistory(sessions, query, options = {}) {
262619
262806
  const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
262620
262807
  const snippetLimit = options.snippetLimit ?? DEFAULT_SNIPPET_LIMIT;
@@ -347336,19 +347523,19 @@ async function runFileMutationTool(args, context2, toolName, unavailableOperatio
347336
347523
  if (context2.signal?.aborted) throw new Error("AbortError");
347337
347524
  const resolved = resolveTerminalForTool(context2, tabIdOrName);
347338
347525
  if (!resolved.ok) {
347339
- const errorText = resolved.message;
347526
+ const errorText2 = resolved.message;
347340
347527
  sendEvent(sessionId, {
347341
347528
  messageId,
347342
347529
  type: "tool_call",
347343
347530
  toolName,
347344
347531
  input: JSON.stringify(args),
347345
- output: errorText
347532
+ output: errorText2
347346
347533
  });
347347
- return errorText;
347534
+ return errorText2;
347348
347535
  }
347349
347536
  const bestMatch = resolved.terminal;
347350
347537
  if (!resolved.snapshot.canUseFilesystem) {
347351
- const errorText = bestMatch.capabilities?.supportsFilesystem !== true && resolved.snapshot.runtimeState === "ready" ? `Error: Terminal tab "${bestMatch.title || bestMatch.id}" (id=${bestMatch.id}, type=${bestMatch.type}) does not support filesystem operations.` : formatTerminalUnavailableForTool(
347538
+ const errorText2 = bestMatch.capabilities?.supportsFilesystem !== true && resolved.snapshot.runtimeState === "ready" ? `Error: Terminal tab "${bestMatch.title || bestMatch.id}" (id=${bestMatch.id}, type=${bestMatch.type}) does not support filesystem operations.` : formatTerminalUnavailableForTool(
347352
347539
  resolved.snapshot,
347353
347540
  unavailableOperation
347354
347541
  );
@@ -347356,12 +347543,12 @@ async function runFileMutationTool(args, context2, toolName, unavailableOperatio
347356
347543
  messageId,
347357
347544
  type: "file_edit",
347358
347545
  toolName,
347359
- output: errorText,
347546
+ output: errorText2,
347360
347547
  filePath: filePathInput,
347361
347548
  action: "error",
347362
347549
  diff: ""
347363
347550
  });
347364
- return errorText;
347551
+ return errorText2;
347365
347552
  }
347366
347553
  let outputText = "";
347367
347554
  let diffText2 = "";
@@ -349537,15 +349724,15 @@ async function readCommandOutput(args, context2) {
349537
349724
  abortIfNeeded(context2.signal);
349538
349725
  const terminalResolution = resolveTerminalForTool(context2, tabIdOrName);
349539
349726
  if (!terminalResolution.ok) {
349540
- const errorText = terminalResolution.message;
349727
+ const errorText2 = terminalResolution.message;
349541
349728
  sendEvent(sessionId, {
349542
349729
  messageId,
349543
349730
  type: "tool_call",
349544
349731
  toolName: "read_command_output",
349545
349732
  input: JSON.stringify(args ?? {}),
349546
- output: errorText
349733
+ output: errorText2
349547
349734
  });
349548
- return errorText;
349735
+ return errorText2;
349549
349736
  }
349550
349737
  const bestMatch = terminalResolution.terminal;
349551
349738
  const task2 = terminalService.getCommandTask(bestMatch.id, history_command_match_id);
@@ -349555,16 +349742,16 @@ async function readCommandOutput(args, context2) {
349555
349742
  const started = new Date(t.startTime).toISOString();
349556
349743
  return `- id: ${t.id}, status: ${t.status}, command: ${t.command}, started: ${started}`;
349557
349744
  }).join("\n") : "(No command history for this terminal)";
349558
- const errorText = `Error: history_command_match_id "${history_command_match_id}" not found in terminal "${bestMatch.title || bestMatch.id}".
349745
+ const errorText2 = `Error: history_command_match_id "${history_command_match_id}" not found in terminal "${bestMatch.title || bestMatch.id}".
349559
349746
  ${history2}`;
349560
349747
  sendEvent(sessionId, {
349561
349748
  messageId,
349562
349749
  type: "tool_call",
349563
349750
  toolName: "read_command_output",
349564
349751
  input: JSON.stringify(args ?? {}),
349565
- output: errorText
349752
+ output: errorText2
349566
349753
  });
349567
- return errorText;
349754
+ return errorText2;
349568
349755
  }
349569
349756
  const output = task2.output || "";
349570
349757
  const isRunning = task2.status === "running";
@@ -349600,19 +349787,19 @@ async function writeStdin(args, context2) {
349600
349787
  abortIfNeeded(context2.signal);
349601
349788
  const terminalResolution = resolveTerminalForTool(context2, tabIdOrName);
349602
349789
  if (!terminalResolution.ok) {
349603
- const errorText = terminalResolution.message;
349790
+ const errorText2 = terminalResolution.message;
349604
349791
  sendEvent(sessionId, {
349605
349792
  messageId,
349606
349793
  type: "tool_call",
349607
349794
  toolName: "write_stdin",
349608
349795
  input: JSON.stringify(sequence ?? []),
349609
- output: errorText
349796
+ output: errorText2
349610
349797
  });
349611
- return errorText;
349798
+ return errorText2;
349612
349799
  }
349613
349800
  const bestMatch = terminalResolution.terminal;
349614
349801
  if (!terminalResolution.snapshot.canWrite) {
349615
- const errorText = formatTerminalUnavailableForTool(
349802
+ const errorText2 = formatTerminalUnavailableForTool(
349616
349803
  terminalResolution.snapshot,
349617
349804
  "send input to this terminal"
349618
349805
  );
@@ -349621,9 +349808,9 @@ async function writeStdin(args, context2) {
349621
349808
  type: "tool_call",
349622
349809
  toolName: "write_stdin",
349623
349810
  input: JSON.stringify(sequence ?? []),
349624
- output: errorText
349811
+ output: errorText2
349625
349812
  });
349626
- return errorText;
349813
+ return errorText2;
349627
349814
  }
349628
349815
  const commandText = (sequence ?? []).join("");
349629
349816
  const allowed = await checkCommandPolicy(commandText, "write_stdin", context2);
@@ -365917,6 +366104,9 @@ async function invokeWithRetry(fn, maxRetries = 4, delays = [1e3, 2e3, 4e3, 6e3]
365917
366104
  if (isAbortError3(error40)) {
365918
366105
  throw error40;
365919
366106
  }
366107
+ if (!isRetryableError(error40)) {
366108
+ throw error40;
366109
+ }
365920
366110
  if (attempt < maxRetries - 1) {
365921
366111
  const delay = delays[attempt];
365922
366112
  console.warn(`[AgentService] Model invocation failed (Attempt ${attempt + 1}/${maxRetries}). Error: ${error40.message}. Retrying in ${delay}ms...`);
@@ -365935,8 +366125,80 @@ async function invokeWithRetry(fn, maxRetries = 4, delays = [1e3, 2e3, 4e3, 6e3]
365935
366125
  }
365936
366126
  throw lastError;
365937
366127
  }
366128
+ function errorText(error40) {
366129
+ const parts = [];
366130
+ if (typeof error40?.message === "string") parts.push(error40.message);
366131
+ if (typeof error40?.error?.message === "string") parts.push(error40.error.message);
366132
+ if (typeof error40?.code === "string") parts.push(error40.code);
366133
+ if (typeof error40?.status === "number") parts.push(String(error40.status));
366134
+ if (typeof error40?.statusCode === "number") parts.push(String(error40.statusCode));
366135
+ if (typeof error40?.cause?.code === "string") parts.push(error40.cause.code);
366136
+ return parts.join(" ").toLowerCase();
366137
+ }
365938
366138
  function isRetryableError(error40) {
365939
- return !isAbortError3(error40);
366139
+ if (isAbortError3(error40)) return false;
366140
+ const text = errorText(error40);
366141
+ if (!text) return false;
366142
+ const finishMatch = text.match(/finish_reason=([^.)]*)/);
366143
+ if (finishMatch) {
366144
+ const reasons = finishMatch[1].split(/[,\s]+/).filter(Boolean);
366145
+ if (reasons.includes("error")) return true;
366146
+ const deterministicReasons = /* @__PURE__ */ new Set(["length", "content_filter"]);
366147
+ if (reasons.length > 0 && reasons.every((r) => deterministicReasons.has(r))) {
366148
+ return false;
366149
+ }
366150
+ }
366151
+ const deterministic = [
366152
+ "empty unusable response",
366153
+ "cannot read properties of undefined",
366154
+ "cannot read properties of null",
366155
+ "is not a function",
366156
+ "bad request",
366157
+ "invalid_request",
366158
+ "validation",
366159
+ "context_length",
366160
+ "context length",
366161
+ "maximum context",
366162
+ "too many tokens",
366163
+ "unsupported",
366164
+ "malformed",
366165
+ "unauthorized",
366166
+ "forbidden",
366167
+ "model_not_found",
366168
+ "insufficient_quota",
366169
+ "invalid api key",
366170
+ "invalid_api_key"
366171
+ ];
366172
+ if (deterministic.some((needle) => text.includes(needle))) return false;
366173
+ const transient = [
366174
+ "econnreset",
366175
+ "econnrefused",
366176
+ "etimedout",
366177
+ "esockettimedout",
366178
+ "epipe",
366179
+ "enotfound",
366180
+ "eai_again",
366181
+ "socket hang up",
366182
+ "network",
366183
+ "fetch failed",
366184
+ "connection reset",
366185
+ "connection error",
366186
+ "premature close",
366187
+ "timed out",
366188
+ "timeout",
366189
+ "terminated",
366190
+ "502",
366191
+ "503",
366192
+ "504",
366193
+ "429",
366194
+ "rate limit",
366195
+ "overloaded",
366196
+ "server error",
366197
+ "bad gateway",
366198
+ "service unavailable",
366199
+ "gateway timeout"
366200
+ ];
366201
+ return transient.some((needle) => text.includes(needle));
365940
366202
  }
365941
366203
  function extractErrorDetails(error40) {
365942
366204
  let details = "";
@@ -366141,6 +366403,28 @@ function getStreamedResponseModelName(response, rawChunks) {
366141
366403
  (candidate) => typeof candidate === "string" && candidate.trim().length > 0
366142
366404
  );
366143
366405
  }
366406
+ var KNOWN_FINISH_REASONS = [
366407
+ "stop",
366408
+ "length",
366409
+ "tool_calls",
366410
+ "content_filter",
366411
+ "function_call",
366412
+ "error"
366413
+ ];
366414
+ function normalizeFinishReason(reason) {
366415
+ if (typeof reason !== "string") return "";
366416
+ const trimmed = reason.trim();
366417
+ if (!trimmed) return "";
366418
+ const lowered = trimmed.toLowerCase();
366419
+ for (const known of KNOWN_FINISH_REASONS) {
366420
+ for (let repeats = 5; repeats >= 2; repeats--) {
366421
+ if (lowered === known.repeat(repeats)) {
366422
+ return known;
366423
+ }
366424
+ }
366425
+ }
366426
+ return trimmed;
366427
+ }
366144
366428
  function isEmptyMalformedToolCallFinish(response, rawChunks) {
366145
366429
  if (!hasToolCallFinishReason(response, rawChunks)) return false;
366146
366430
  if (hasAnyToolCallPayload(response, rawChunks)) return false;
@@ -366198,7 +366482,7 @@ function extractRawResponseMetadata(rawChunk) {
366198
366482
  }
366199
366483
  function getRawFinishReason(rawChunk) {
366200
366484
  const choices = Array.isArray(rawChunk?.choices) ? rawChunk.choices : [];
366201
- return choices.map((choice) => choice?.finish_reason ?? choice?.finishReason).find((reason) => hasNonEmptyString(reason));
366485
+ return choices.map((choice) => normalizeFinishReason(choice?.finish_reason ?? choice?.finishReason)).find((reason) => hasNonEmptyString(reason));
366202
366486
  }
366203
366487
  function getFinishReasons(response, rawChunks) {
366204
366488
  const candidates = [
@@ -366212,7 +366496,7 @@ function getFinishReasons(response, rawChunks) {
366212
366496
  ) : []
366213
366497
  )
366214
366498
  ];
366215
- return candidates.filter(hasNonEmptyString);
366499
+ return candidates.map((c) => normalizeFinishReason(c)).filter(hasNonEmptyString);
366216
366500
  }
366217
366501
  function getMessageType(message) {
366218
366502
  const type2 = typeof message?._getType === "function" ? message._getType() : message?.type;
@@ -371635,9 +371919,11 @@ ${reminder}`;
371635
371919
  messages = [...messages, this.lastAbortedMessage];
371636
371920
  this.lastAbortedMessage = null;
371637
371921
  }
371638
- const session = this.chatHistoryService.loadSession(sessionId) || {
371922
+ const sessionMeta = this.chatHistoryService.getSessionMeta(sessionId);
371923
+ const session = {
371639
371924
  id: sessionId,
371640
- title: "New Session",
371925
+ title: sessionMeta?.title || "New Session",
371926
+ // Placeholder only — replaced by updateSessionFromMessages() below.
371641
371927
  messages: /* @__PURE__ */ new Map(),
371642
371928
  lastCheckpointOffset: 0,
371643
371929
  lastProfileMaxTokens: this.getEffectiveMaxTokensForSession(sessionId)
@@ -373138,7 +373424,7 @@ var ChatHistoryService = class {
373138
373424
  this.store = options?.store || new HistorySqliteStore();
373139
373425
  }
373140
373426
  saveSession(session) {
373141
- const existing = this.store.loadChatSession(session.id);
373427
+ const createdAt = this.store.getChatSessionCreatedAt(session.id);
373142
373428
  const now = Date.now();
373143
373429
  this.store.saveChatSession({
373144
373430
  id: session.id,
@@ -373150,10 +373436,18 @@ var ChatHistoryService = class {
373150
373436
  })),
373151
373437
  lastCheckpointOffset: session.lastCheckpointOffset,
373152
373438
  lastProfileMaxTokens: session.lastProfileMaxTokens,
373153
- createdAt: existing?.createdAt || now,
373439
+ createdAt: createdAt || now,
373154
373440
  updatedAt: now
373155
373441
  });
373156
373442
  }
373443
+ /**
373444
+ * Session row only (title/createdAt) — never parses stored messages.
373445
+ * Used on the checkpoint save path, which previously paid a full session
373446
+ * parse just to preserve the title.
373447
+ */
373448
+ getSessionMeta(sessionId) {
373449
+ return this.store.getChatSessionMeta(sessionId);
373450
+ }
373157
373451
  loadSession(sessionId) {
373158
373452
  const storedSession = this.store.loadChatSession(sessionId);
373159
373453
  if (!storedSession) {
@@ -374788,20 +375082,24 @@ var WebSocketGatewayAdapter = class {
374788
375082
  }
374789
375083
  case "history:search": {
374790
375084
  const bridge = this.options.historyBridge;
374791
- if (!bridge?.getAllSessions) {
375085
+ if (!bridge?.searchBounded && !bridge?.getAllSessions) {
374792
375086
  throw new WebSocketRpcError(
374793
375087
  "METHOD_NOT_FOUND",
374794
375088
  "history:search is not available on this gateway (no history bridge)."
374795
375089
  );
374796
375090
  }
374797
- const { searchChatHistory: searchChatHistory2 } = await Promise.resolve().then(() => (init_historySearch(), historySearch_exports));
374798
- const sessions = await bridge.getAllSessions();
374799
375091
  const query = this.readStringParam(params, "query");
374800
375092
  const wholeWord = params?.wholeWord === true;
374801
375093
  const includeTitles = params?.includeTitles !== false;
374802
375094
  const sessionLimit = typeof params?.sessionLimit === "number" ? params.sessionLimit : void 0;
374803
375095
  const snippetLimit = typeof params?.snippetLimit === "number" ? params.snippetLimit : void 0;
374804
- return searchChatHistory2(sessions, query, { wholeWord, includeTitles, sessionLimit, snippetLimit });
375096
+ const options = { wholeWord, includeTitles, sessionLimit, snippetLimit };
375097
+ if (bridge.searchBounded) {
375098
+ return await bridge.searchBounded(query, options);
375099
+ }
375100
+ const { searchChatHistory: searchChatHistory2 } = await Promise.resolve().then(() => (init_historySearch(), historySearch_exports));
375101
+ const sessions = await bridge.getAllSessions();
375102
+ return searchChatHistory2(sessions, query, options);
374805
375103
  }
374806
375104
  case "settings:listBackups": {
374807
375105
  const bridge = this.options.settingsBridge;
@@ -386881,6 +387179,7 @@ var HistoryStorageMigration = class {
386881
387179
 
386882
387180
  // ../../packages/backend/src/runtimes/gybackend/startGyBackend.ts
386883
387181
  init_HistorySqliteStore();
387182
+ init_historySearch();
386884
387183
 
386885
387184
  // ../../packages/backend/src/services/AgentSettingProfileService.ts
386886
387185
  var AgentSettingProfileService = class {
@@ -397894,7 +398193,18 @@ async function startGyBackend() {
397894
398193
  );
397895
398194
  }
397896
398195
  return agentService.getAllChatHistory() ?? [];
397897
- }
398196
+ },
398197
+ // v3.8.4 FREEZE FIX: the gateway's history:search called
398198
+ // `getAllSessions()`, which JSON.parses EVERY message of EVERY
398199
+ // session synchronously — seconds of blocked event loop on a large
398200
+ // store, freezing the UI. This searches one session at a time and
398201
+ // yields between them, so nothing blocks.
398202
+ searchBounded: async (query, options) => searchChatHistoryBounded(
398203
+ () => historyStore.listChatSessionSummaries(),
398204
+ (id) => historyStore.loadChatSession(id),
398205
+ query,
398206
+ options ?? {}
398207
+ )
397898
398208
  },
397899
398209
  commandPolicyBridge: {
397900
398210
  getLists: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.8.2",
3
+ "version": "3.8.4",
4
4
  "description": "AI-native terminal & agentic-AI operations platform for Forward Deployed Engineers & SREs: AIOps closed-loop remediation, AI SRE, self-healing infrastructure, runbook automation, ChatOps; executes over SSH/WinRM/serial under policy with tamper-evident audit.",
5
5
  "keywords": [
6
6
  "forward-deployed-engineer",