rterm-backend 3.8.2 → 3.8.3

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 +175 -25
  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"() {
@@ -258656,9 +258659,135 @@ var init_HistorySqliteStore = __esm({
258656
258659
  (session) => session !== null
258657
258660
  );
258658
258661
  }
258662
+ /**
258663
+ * Session meta WITHOUT messages.
258664
+ *
258665
+ * Two fixed-cost hot-path bugs this closes:
258666
+ *
258667
+ * 1. `ChatHistoryService.saveSession` called `loadChatSession()` purely to
258668
+ * learn `createdAt`. That JSON.parse'd the ENTIRE session — measured
258669
+ * ~117 MB / ~1.5 s of parse for a 6k-message session — and threw every
258670
+ * parsed message away, on every save.
258671
+ * 2. `AgentService_v2.trySaveSessionFromCheckpoint` called `loadSession()`
258672
+ * for a default it never used, because `updateSessionFromMessages()`
258673
+ * rebuilds `session.messages` from scratch. Dropping that call outright
258674
+ * would have reset the session TITLE to "New Session" on every restore,
258675
+ * so the title is read here too.
258676
+ */
258677
+ getChatSessionMeta(sessionId) {
258678
+ const row = this.db.prepare("SELECT created_at, title FROM chat_sessions WHERE id = ?").get(sessionId);
258679
+ return row ? { createdAt: row.created_at, title: row.title } : null;
258680
+ }
258681
+ /** Created-at scalar only — a single indexed row, never a message parse. */
258682
+ getChatSessionCreatedAt(sessionId) {
258683
+ return this.getChatSessionMeta(sessionId)?.createdAt;
258684
+ }
258685
+ /**
258686
+ * Existing message bodies as RAW TEXT, ordered by position, WITHOUT parsing.
258687
+ *
258688
+ * The raw `message_data_json` is needed for a byte-exact comparison against
258689
+ * the newly serialized body. Deliberately NOT JSON.parse'd: the parse is the
258690
+ * expensive half of the freeze (~1.5 s / 117 MB measured) and is unnecessary
258691
+ * for an equality test.
258692
+ *
258693
+ * (An earlier revision compared only `substr(json, 1, 200)`. That was WRONG:
258694
+ * a message whose content grows past the first 200 characters — exactly what
258695
+ * streaming does to the last AI message — leaves the prefix untouched, so the
258696
+ * row would be skipped and the persisted history would silently stay STALE.
258697
+ * Full-text comparison is what makes "unchanged => skip" safe.)
258698
+ */
258699
+ loadSessionMessageState(sessionId) {
258700
+ const rows = this.db.prepare(
258701
+ `SELECT message_id, message_type, position, message_data_json
258702
+ FROM chat_session_messages
258703
+ WHERE session_id = ?
258704
+ ORDER BY position ASC`
258705
+ ).all(sessionId);
258706
+ return rows.map((row) => ({
258707
+ id: row.message_id,
258708
+ type: row.message_type,
258709
+ position: row.position,
258710
+ jsonText: row.message_data_json ?? ""
258711
+ }));
258712
+ }
258713
+ /**
258714
+ * Incremental write: touch only the rows that actually moved.
258715
+ *
258716
+ * The old path ran `DELETE ALL` + `INSERT ALL` on every save, rewriting the
258717
+ * whole session payload each time. Here, unchanged messages stay on disk
258718
+ * untouched; messages that are genuinely absent (compaction, rollback) are
258719
+ * removed individually.
258720
+ */
258721
+ applyChatSessionDelta(sessionId, desired, existingDigests, writeAll) {
258722
+ const desiredIds = /* @__PURE__ */ new Set();
258723
+ for (const m2 of desired) {
258724
+ desiredIds.add(m2.id);
258725
+ }
258726
+ const changed = writeAll ? desired : desired.filter((m2) => existingDigests.get(m2.id) !== digestFor(m2));
258727
+ const toRemove = [];
258728
+ for (const id of existingDigests.keys()) {
258729
+ if (!desiredIds.has(id)) {
258730
+ toRemove.push(id);
258731
+ }
258732
+ }
258733
+ if (changed.length === 0 && toRemove.length === 0) {
258734
+ return;
258735
+ }
258736
+ const upsert = this.db.prepare(
258737
+ `INSERT INTO chat_session_messages (
258738
+ session_id, position, message_id, message_type, message_data_json
258739
+ ) VALUES (?, ?, ?, ?, ?)
258740
+ ON CONFLICT(session_id, message_id) DO UPDATE SET
258741
+ position = excluded.position,
258742
+ message_type = excluded.message_type,
258743
+ message_data_json = excluded.message_data_json`
258744
+ );
258745
+ const removeOne = this.db.prepare(
258746
+ "DELETE FROM chat_session_messages WHERE session_id = ? AND message_id = ?"
258747
+ );
258748
+ this.db.transaction(() => {
258749
+ for (const id of toRemove) {
258750
+ removeOne.run(sessionId, id);
258751
+ }
258752
+ for (const m2 of changed) {
258753
+ upsert.run(sessionId, m2.position, m2.id, m2.type, m2.dataJson);
258754
+ }
258755
+ })();
258756
+ }
258757
+ /**
258758
+ * Full positional rewrite. Positions are `PRIMARY KEY (session_id,
258759
+ * position)`, so an in-place UPDATE cannot express a reorder without a
258760
+ * transient collision — the order guard in saveChatSession routes here when
258761
+ * (and only when) the surviving ids changed relative order.
258762
+ *
258763
+ * Therefore this really does DELETE + INSERT rather than upsert. An
258764
+ * `ON CONFLICT(session_id, message_id)` upsert looks like it would work but
258765
+ * does NOT: reassigning `position` collides on the (session_id, position)
258766
+ * PRIMARY KEY against a row that has not moved yet, and that conflict is not
258767
+ * absorbed by an ON CONFLICT clause targeting a different key —
258768
+ * SqliteError: UNIQUE constraint failed:
258769
+ * chat_session_messages.session_id, chat_session_messages.position
258770
+ * Clearing first (inside the same transaction) sidesteps the ordering
258771
+ * problem entirely. This path is rare by design, so the rewrite is cheap
258772
+ * enough, and it matches the old DELETE-ALL/INSERT-ALL semantics exactly.
258773
+ */
258774
+ replaceAllMessages(sessionId, desired) {
258775
+ const removeAll = this.db.prepare(
258776
+ "DELETE FROM chat_session_messages WHERE session_id = ?"
258777
+ );
258778
+ const insert = this.db.prepare(
258779
+ `INSERT INTO chat_session_messages (
258780
+ session_id, position, message_id, message_type, message_data_json
258781
+ ) VALUES (?, ?, ?, ?, ?)`
258782
+ );
258783
+ this.db.transaction(() => {
258784
+ removeAll.run(sessionId);
258785
+ desired.forEach((m2, index2) => {
258786
+ insert.run(sessionId, index2, m2.id, m2.type, m2.dataJson);
258787
+ });
258788
+ })();
258789
+ }
258659
258790
  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
258791
  const upsertSession = this.db.prepare(
258663
258792
  `INSERT INTO chat_sessions (
258664
258793
  id, title, last_checkpoint_offset, last_profile_max_tokens, created_at, updated_at
@@ -258671,34 +258800,45 @@ var init_HistorySqliteStore = __esm({
258671
258800
  last_profile_max_tokens = excluded.last_profile_max_tokens,
258672
258801
  updated_at = excluded.updated_at`
258673
258802
  );
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
258803
  this.db.transaction(() => {
258683
258804
  upsertSession.run({
258684
258805
  id: session.id,
258685
258806
  title: session.title,
258686
258807
  lastCheckpointOffset: session.lastCheckpointOffset,
258687
258808
  lastProfileMaxTokens: session.lastProfileMaxTokens ?? null,
258688
- createdAt,
258809
+ createdAt: session.createdAt,
258689
258810
  updatedAt: session.updatedAt
258690
258811
  });
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
258812
  })();
258813
+ const desired = session.messages.map(
258814
+ (message, index2) => ({
258815
+ id: message.id,
258816
+ type: message.type,
258817
+ position: index2,
258818
+ dataJson: JSON.stringify(message.data)
258819
+ })
258820
+ );
258821
+ const existingState = this.loadSessionMessageState(session.id);
258822
+ const existingDigests = /* @__PURE__ */ new Map();
258823
+ for (const row of existingState) {
258824
+ existingDigests.set(
258825
+ row.id,
258826
+ digestFor({ type: row.type, dataJson: row.jsonText })
258827
+ );
258828
+ }
258829
+ const storedOrder = existingState.map((row) => row.id);
258830
+ const desiredOrderOfStored = [];
258831
+ for (const m2 of desired) {
258832
+ if (existingDigests.has(m2.id)) {
258833
+ desiredOrderOfStored.push(m2.id);
258834
+ }
258835
+ }
258836
+ const reordered = storedOrder.length !== desiredOrderOfStored.length || storedOrder.some((id, i) => id !== desiredOrderOfStored[i]);
258837
+ if (reordered) {
258838
+ this.replaceAllMessages(session.id, desired);
258839
+ return;
258840
+ }
258841
+ this.applyChatSessionDelta(session.id, desired, existingDigests, false);
258702
258842
  }
258703
258843
  deleteChatSessions(sessionIds) {
258704
258844
  const ids = Array.from(
@@ -371635,9 +371775,11 @@ ${reminder}`;
371635
371775
  messages = [...messages, this.lastAbortedMessage];
371636
371776
  this.lastAbortedMessage = null;
371637
371777
  }
371638
- const session = this.chatHistoryService.loadSession(sessionId) || {
371778
+ const sessionMeta = this.chatHistoryService.getSessionMeta(sessionId);
371779
+ const session = {
371639
371780
  id: sessionId,
371640
- title: "New Session",
371781
+ title: sessionMeta?.title || "New Session",
371782
+ // Placeholder only — replaced by updateSessionFromMessages() below.
371641
371783
  messages: /* @__PURE__ */ new Map(),
371642
371784
  lastCheckpointOffset: 0,
371643
371785
  lastProfileMaxTokens: this.getEffectiveMaxTokensForSession(sessionId)
@@ -373138,7 +373280,7 @@ var ChatHistoryService = class {
373138
373280
  this.store = options?.store || new HistorySqliteStore();
373139
373281
  }
373140
373282
  saveSession(session) {
373141
- const existing = this.store.loadChatSession(session.id);
373283
+ const createdAt = this.store.getChatSessionCreatedAt(session.id);
373142
373284
  const now = Date.now();
373143
373285
  this.store.saveChatSession({
373144
373286
  id: session.id,
@@ -373150,10 +373292,18 @@ var ChatHistoryService = class {
373150
373292
  })),
373151
373293
  lastCheckpointOffset: session.lastCheckpointOffset,
373152
373294
  lastProfileMaxTokens: session.lastProfileMaxTokens,
373153
- createdAt: existing?.createdAt || now,
373295
+ createdAt: createdAt || now,
373154
373296
  updatedAt: now
373155
373297
  });
373156
373298
  }
373299
+ /**
373300
+ * Session row only (title/createdAt) — never parses stored messages.
373301
+ * Used on the checkpoint save path, which previously paid a full session
373302
+ * parse just to preserve the title.
373303
+ */
373304
+ getSessionMeta(sessionId) {
373305
+ return this.store.getChatSessionMeta(sessionId);
373306
+ }
373157
373307
  loadSession(sessionId) {
373158
373308
  const storedSession = this.store.loadChatSession(sessionId);
373159
373309
  if (!storedSession) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.8.2",
3
+ "version": "3.8.3",
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",