@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 +7 -0
- package/dist/core/device-auth.js +33 -6
- package/dist/core/rubix-api.js +58 -29
- package/dist/ui/App.js +25 -30
- package/package.json +1 -1
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
|
package/dist/core/device-auth.js
CHANGED
|
@@ -130,24 +130,51 @@ async function getUserProfile(idToken) {
|
|
|
130
130
|
}
|
|
131
131
|
return (await response.json());
|
|
132
132
|
}
|
|
133
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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);
|
package/dist/core/rubix-api.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 (
|
|
144
|
+
if (existingTrimmed.endsWith(add.slice(0, k))) {
|
|
146
145
|
const newPart = add.slice(k);
|
|
147
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
|
1423
|
-
const
|
|
1409
|
+
const inTrim = incoming.trim();
|
|
1410
|
+
const prevTrim = previous.trim();
|
|
1424
1411
|
const isPartial = event.details?.partial === true;
|
|
1425
|
-
|
|
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:
|
|
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 (
|
|
1435
|
+
if (previous === incoming)
|
|
1441
1436
|
return message;
|
|
1442
1437
|
}
|
|
1443
1438
|
existing.push(event);
|
package/package.json
CHANGED