@rubixkube/rubix 0.0.6 → 0.0.7

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
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
+ ## [0.0.7] - 2025-03-18
9
+
10
+ ### Added
11
+
12
+ - Bugfixes and performance improvements
13
+ - Introducing Sarvam 105B support
14
+
8
15
  ## [0.0.6] - 2025-03-08
9
16
 
10
17
  ### Added
@@ -130,24 +130,51 @@ async function getUserProfile(idToken) {
130
130
  }
131
131
  return (await response.json());
132
132
  }
133
- function openUrlInBrowser(url) {
133
+ /** Detect headless environment (SSH, CI, no display server) */
134
+ export function isHeadlessEnvironment() {
135
+ if (process.env.CI === "true" || process.env.CI === "1")
136
+ return true;
137
+ if (process.platform === "linux") {
138
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
139
+ return true;
140
+ }
141
+ return false;
142
+ }
143
+ /**
144
+ * Open URL in browser. No-op in headless environments.
145
+ * Handles spawn errors (e.g. ENOENT for missing xdg-open) without crashing.
146
+ */
147
+ export function openUrlSafely(url) {
134
148
  const urlStr = String(url).trim();
135
149
  if (!urlStr.startsWith("http://") && !urlStr.startsWith("https://"))
136
150
  return;
151
+ if (isHeadlessEnvironment())
152
+ return;
153
+ let proc;
137
154
  if (process.platform === "darwin") {
138
- spawn("open", [urlStr], { stdio: "ignore" });
155
+ proc = spawn("open", [urlStr], { stdio: "ignore" });
139
156
  }
140
157
  else if (process.platform === "win32") {
141
- spawn("cmd", ["/c", "start", "", urlStr], { stdio: "ignore" });
158
+ proc = spawn("cmd", ["/c", "start", "", urlStr], { stdio: "ignore" });
142
159
  }
143
160
  else {
144
- spawn("xdg-open", [urlStr], { stdio: "ignore" });
161
+ proc = spawn("xdg-open", [urlStr], { stdio: "ignore" });
145
162
  }
163
+ proc.on("error", () => {
164
+ /* Swallow ENOENT and other spawn errors to avoid process crash */
165
+ });
146
166
  }
147
167
  export async function authenticateWithDeviceFlow(log) {
148
168
  const start = await startDeviceAuth();
149
- openUrlInBrowser(start.verificationUrl);
150
- log?.(`Opening ${start.verificationUrl} in browser`);
169
+ const headless = isHeadlessEnvironment();
170
+ openUrlSafely(start.verificationUrl);
171
+ if (headless) {
172
+ log?.("No display detected (SSH/headless). Open this URL in a browser on another device:");
173
+ log?.(start.verificationUrl);
174
+ }
175
+ else {
176
+ log?.(`Opening ${start.verificationUrl} in browser`);
177
+ }
151
178
  log?.(`Enter code: ${start.userCode}`);
152
179
  const tokens = await pollForTokens(start, log);
153
180
  const validation = await validateAuth0Token(tokens.id_token);
@@ -134,21 +134,26 @@ function mergeChunks(existing, add) {
134
134
  return add; // cumulative resend
135
135
  if (existing.startsWith(add))
136
136
  return existing; // duplicate smaller resend
137
- if (add.includes(existing))
138
- return add; // cumulative with prefix text
139
- if (existing.includes(add))
140
- return existing; // already included
141
- // Find maximal overlap where existing ends with the prefix of add
142
- // Matches console/opel handling: no guard that could drop partial chunks
143
- const maxK = Math.min(existing.length, add.length);
137
+ // Never use includes() — e.g. "I'" matches inside "I'm", "|" matches in markdown; would silently eat content.
138
+ // Find maximal overlap where existing ends with the prefix of add.
139
+ // Use trimEnd when checking so "Investigate " + "igate..." merges correctly
140
+ // (trailing space would otherwise block suffix overlap).
141
+ const existingTrimmed = existing.replace(/\s+$/, "");
142
+ const maxK = Math.min(existingTrimmed.length, add.length);
144
143
  for (let k = maxK; k > 0; k -= 1) {
145
- if (existing.endsWith(add.slice(0, k))) {
144
+ if (existingTrimmed.endsWith(add.slice(0, k))) {
146
145
  const newPart = add.slice(k);
147
- return newPart ? existing + newPart : existing;
146
+ if (!newPart)
147
+ return existing;
148
+ return existingTrimmed + newPart;
148
149
  }
149
150
  }
150
151
  return existing + add;
151
152
  }
153
+ /** Exported for thought accumulation in UI (overlap-aware merge). */
154
+ export function mergeTextChunks(existing, add) {
155
+ return mergeChunks(existing, add);
156
+ }
152
157
  function asText(value) {
153
158
  if (typeof value === "string")
154
159
  return value;
@@ -510,21 +515,27 @@ export async function fetchChatHistory(auth, sessionId, pageSize = 20, appName =
510
515
  }
511
516
  const turn = turns.get(invId);
512
517
  const parts = parseParts(event.content);
518
+ let thoughtBuffer = ""; // Accumulate consecutive thought parts (raw concat) for token-based storage
513
519
  for (const part of parts) {
514
- // Thought
515
- if (part.thought === true) {
516
- if (typeof part.text === "string" && part.text.trim()) {
517
- const wev = {
518
- id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
519
- type: "thought",
520
- content: part.text.trim(),
521
- ts,
522
- };
523
- turn.workflow.push(wev);
524
- pushWorkflowSegment(turn.segments, wev);
525
- }
520
+ const partText = typeof part.text === "string" ? part.text : "";
521
+ const isThoughtPart = part.thought === true || part.type === "thought";
522
+ // Thought: accumulate consecutive parts into one entry (LiteLLM stores 100 tokens = 100 parts)
523
+ if (isThoughtPart) {
524
+ thoughtBuffer += partText;
526
525
  continue;
527
526
  }
527
+ // Flush accumulated thoughts before processing non-thought part
528
+ if (thoughtBuffer.length > 0) {
529
+ const wev = {
530
+ id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
531
+ type: "thought",
532
+ content: thoughtBuffer,
533
+ ts,
534
+ };
535
+ turn.workflow.push(wev);
536
+ pushWorkflowSegment(turn.segments, wev);
537
+ thoughtBuffer = "";
538
+ }
528
539
  // Tool call
529
540
  const fc = part.functionCall ?? part.function_call;
530
541
  if (fc) {
@@ -555,8 +566,9 @@ export async function fetchChatHistory(auth, sessionId, pageSize = 20, appName =
555
566
  pushWorkflowSegment(turn.segments, wev);
556
567
  continue;
557
568
  }
558
- // Plain text — user bubble if author === "user", otherwise assistant bubble
559
- if (typeof part.text === "string" && part.text.trim()) {
569
+ // Plain text — user bubble if author === "user", otherwise assistant bubble.
570
+ // Skip whitespace-only parts (separators in stored data).
571
+ if (typeof part.text === "string" && part.text.trim().length > 0) {
560
572
  if (event.author === "user") {
561
573
  turn.userText = turn.userText ? `${turn.userText}\n${part.text}` : part.text;
562
574
  }
@@ -567,6 +579,17 @@ export async function fetchChatHistory(auth, sessionId, pageSize = 20, appName =
567
579
  }
568
580
  }
569
581
  }
582
+ // Flush any remaining thought buffer after last part
583
+ if (thoughtBuffer.length > 0) {
584
+ const wev = {
585
+ id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
586
+ type: "thought",
587
+ content: thoughtBuffer,
588
+ ts,
589
+ };
590
+ turn.workflow.push(wev);
591
+ pushWorkflowSegment(turn.segments, wev);
592
+ }
570
593
  }
571
594
  const messages = [];
572
595
  for (const invId of turnOrder) {
@@ -672,13 +695,17 @@ export async function streamChat(input, callbacks = {}) {
672
695
  }
673
696
  const parts = event.content?.parts ?? [];
674
697
  const isThoughtType = normalizedEventType === "thought" || normalizedEventType === "thoughts";
675
- // Accumulate all visible text from this event (console pattern: batch per event)
698
+ // Accumulate all visible text from this event (console pattern: batch per event).
699
+ // Use mergeChunks per part to avoid duplicates when backend sends overlapping parts.
676
700
  let visibleChunk = "";
677
701
  for (const part of parts) {
678
702
  const partText = typeof part.text === "string" ? part.text : "";
679
- if ((part.thought === true || isThoughtType) && partText.trim()) {
703
+ // Thought: check both part.thought and part.type for safety (LiteLLM vs Gemini).
704
+ // Pass raw tokens — do not trim; BPE tokens carry their own whitespace.
705
+ const isThoughtPart = part.thought === true || part.type === "thought" || isThoughtType;
706
+ if (isThoughtPart && partText.length > 0) {
680
707
  hasWorkflowEvents = true;
681
- callbacks.onWorkflow?.(normalizeWorkflowEvent("thought", partText.trim(), {
708
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("thought", partText, {
682
709
  partial: event.partial === true,
683
710
  }));
684
711
  continue;
@@ -708,9 +735,11 @@ export async function streamChat(input, callbacks = {}) {
708
735
  }));
709
736
  continue;
710
737
  }
711
- // Visible text (non-thought): accumulate for batch merge
712
- if (partText && part.thought !== true) {
713
- visibleChunk += partText;
738
+ // Visible text (non-thought): accumulate with overlap detection.
739
+ // Skip whitespace-only parts (e.g. {text: "\n"}) used as separators in stored data.
740
+ const isVisibleText = part.thought !== true && part.type !== "thought";
741
+ if (isVisibleText && partText.trim().length > 0) {
742
+ visibleChunk = mergeChunks(visibleChunk, partText);
714
743
  }
715
744
  }
716
745
  // Merge accumulated visible text once per event (handles partial/cumulative chunks)
package/dist/ui/App.js CHANGED
@@ -2,19 +2,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { exec, execSync, spawn } from "child_process";
5
+ import { exec, execSync } from "child_process";
6
6
  import { Box, Text, useApp, useInput } from "ink";
7
7
  import { Select, Spinner, StatusMessage } from "@inkjs/ui";
8
8
  import { addWorkflowEvent, mergeOlderSegments, replaceLastThought, updateStreamingText } from "../core/segments.js";
9
9
  import { clearAuthConfig, loadAuthConfig, saveAuthConfig } from "../core/auth-store.js";
10
- import { authenticateWithDeviceFlow, isTokenNearExpiry } from "../core/device-auth.js";
10
+ import { authenticateWithDeviceFlow, isTokenNearExpiry, openUrlSafely } from "../core/device-auth.js";
11
11
  import { isFolderTrusted, trustFolder, untrustFolder } from "../core/trust-store.js";
12
12
  import { clearLocalSessions, saveLocalSessions } from "../core/session-store.js";
13
13
  import { loadSettings, saveSettings } from "../core/settings.js";
14
14
  import { loadWhatsNew } from "../core/whats-new.js";
15
15
  import { checkForUpdate } from "../core/update-check.js";
16
16
  import { VERSION } from "../version.js";
17
- import { createSession, fetchChatHistory, fetchSystemStats, firstHealthyEnvironment, getOrCreateSession, listEnvironments, listModels, listSessions, listApps, refreshAndUpdateAuth, streamChat, StreamError, updateSessionState, } from "../core/rubix-api.js";
17
+ import { createSession, fetchChatHistory, fetchSystemStats, firstHealthyEnvironment, getOrCreateSession, listEnvironments, listModels, listSessions, listApps, refreshAndUpdateAuth, streamChat, StreamError, mergeTextChunks, updateSessionState, } from "../core/rubix-api.js";
18
18
  import { readFileContext, fetchUrlContext, formatContextBlock, } from "../core/file-context.js";
19
19
  import { AnimatedGlyph } from "./components/AnimatedGlyph.js";
20
20
  import { ChatTranscript } from "./components/ChatTranscript.js";
@@ -1019,29 +1019,13 @@ export function App({ initialSessionId, seedPrompt }) {
1019
1019
  switch (command) {
1020
1020
  case "/console": {
1021
1021
  const url = "https://console.rubixkube.ai";
1022
- if (process.platform === "darwin") {
1023
- spawn("open", [url], { stdio: "ignore" });
1024
- }
1025
- else if (process.platform === "win32") {
1026
- spawn("cmd", ["/c", "start", "", url], { stdio: "ignore" });
1027
- }
1028
- else {
1029
- spawn("xdg-open", [url], { stdio: "ignore" });
1030
- }
1022
+ openUrlSafely(url);
1031
1023
  addSystemMessage("Opening https://console.rubixkube.ai in your browser.");
1032
1024
  return;
1033
1025
  }
1034
1026
  case "/docs": {
1035
1027
  const url = "https://docs.rubixkube.ai";
1036
- if (process.platform === "darwin") {
1037
- spawn("open", [url], { stdio: "ignore" });
1038
- }
1039
- else if (process.platform === "win32") {
1040
- spawn("cmd", ["/c", "start", "", url], { stdio: "ignore" });
1041
- }
1042
- else {
1043
- spawn("xdg-open", [url], { stdio: "ignore" });
1044
- }
1028
+ openUrlSafely(url);
1045
1029
  addSystemMessage("Opening https://docs.rubixkube.ai in your browser.");
1046
1030
  return;
1047
1031
  }
@@ -1412,20 +1396,31 @@ export function App({ initialSessionId, seedPrompt }) {
1412
1396
  pendingWorkflowEvents.events.push((message) => {
1413
1397
  const existing = [...(message.workflow ?? [])];
1414
1398
  const last = existing[existing.length - 1];
1415
- const incomingTrim = event.content.trim();
1416
- // Exact duplicate skip
1417
- if (last && last.type === event.type && (last.content ?? "").trim() === incomingTrim) {
1399
+ const incoming = event.content ?? "";
1400
+ const previous = last?.content ?? "";
1401
+ // Exact duplicate — skip (use raw match; works for both Gemini & token streams)
1402
+ if (last && last.type === event.type && previous === incoming) {
1418
1403
  return message;
1419
1404
  }
1420
- // Streaming thought update: extend rather than duplicate
1405
+ // Streaming thought update: extend rather than duplicate.
1406
+ // Merge token fragments (isPartial) but NOT when incoming clearly starts a new thought.
1407
+ // New-thought heuristic: trimmed incoming starts with capital and previous is substantial.
1421
1408
  if (event.type === "thought" && last?.type === "thought") {
1422
- const incoming = event.content.trim();
1423
- const previous = last.content.trim();
1409
+ const inTrim = incoming.trim();
1410
+ const prevTrim = previous.trim();
1424
1411
  const isPartial = event.details?.partial === true;
1425
- if (incoming.length > 0 && (isPartial || incoming.startsWith(previous) || previous.startsWith(incoming))) {
1412
+ const looksLikeNewThought = inTrim.length > 0 &&
1413
+ /^[A-Z]/.test(inTrim) &&
1414
+ prevTrim.length > 20;
1415
+ const isContinuation = inTrim.length > 0 &&
1416
+ (inTrim.startsWith(prevTrim) ||
1417
+ prevTrim.startsWith(inTrim) ||
1418
+ (isPartial && !looksLikeNewThought));
1419
+ if (isContinuation) {
1420
+ const mergedContent = mergeTextChunks(previous, incoming);
1426
1421
  const merged = {
1427
1422
  ...last,
1428
- content: incoming.length >= previous.length ? incoming : previous,
1423
+ content: mergedContent,
1429
1424
  ts: event.ts,
1430
1425
  details: { ...(last.details ?? {}), ...(event.details ?? {}) },
1431
1426
  };
@@ -1437,7 +1432,7 @@ export function App({ initialSessionId, seedPrompt }) {
1437
1432
  isAccumulating: true,
1438
1433
  };
1439
1434
  }
1440
- if (incoming === previous)
1435
+ if (previous === incoming)
1441
1436
  return message;
1442
1437
  }
1443
1438
  existing.push(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubixkube/rubix",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Chat with your infrastructure from the terminal. RubixKube CLI for Site Reliability Intelligence—predict, prevent, and fix failures with AI.",
5
5
  "type": "module",
6
6
  "bin": {