@rubixkube/rubix 0.0.5 → 0.0.6

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,14 @@ 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.6] - 2025-03-08
9
+
10
+ ### Added
11
+
12
+ - Workflow event timeline
13
+ - Added Personality
14
+ - Bugfixes and performance improvements
15
+
8
16
  ## [0.0.5] - 2025-03-06
9
17
 
10
18
  ### Added
@@ -1,6 +1,7 @@
1
1
  import { getConfig } from "../config/env.js";
2
2
  import { refreshAccessToken } from "./device-auth.js";
3
3
  import { saveAuthConfig } from "./auth-store.js";
4
+ import { pushTextSegment, pushWorkflowSegment } from "./segments.js";
4
5
  const DEFAULT_APP_NAME = "Rubix";
5
6
  export class StreamError extends Error {
6
7
  reason;
@@ -130,17 +131,20 @@ function mergeChunks(existing, add) {
130
131
  if (add === existing)
131
132
  return existing;
132
133
  if (add.startsWith(existing))
133
- return add;
134
+ return add; // cumulative resend
134
135
  if (existing.startsWith(add))
135
- return existing;
136
+ return existing; // duplicate smaller resend
136
137
  if (add.includes(existing))
137
- return add;
138
+ return add; // cumulative with prefix text
138
139
  if (existing.includes(add))
139
- return existing;
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
140
143
  const maxK = Math.min(existing.length, add.length);
141
144
  for (let k = maxK; k > 0; k -= 1) {
142
145
  if (existing.endsWith(add.slice(0, k))) {
143
- return existing + add.slice(k);
146
+ const newPart = add.slice(k);
147
+ return newPart ? existing + newPart : existing;
144
148
  }
145
149
  }
146
150
  return existing + add;
@@ -471,9 +475,16 @@ function parseParts(content) {
471
475
  }
472
476
  return content.parts ?? [];
473
477
  }
474
- export async function fetchChatHistory(auth, sessionId, limit = 50) {
478
+ function parseContentRole(content) {
479
+ if (!content || typeof content === "string")
480
+ return "";
481
+ return content.role ?? "";
482
+ }
483
+ export async function fetchChatHistory(auth, sessionId, pageSize = 20, appName = DEFAULT_APP_NAME, offset = 0) {
475
484
  const { userId } = ensureAuth(auth);
476
- const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=0&format=detailed&order_desc=false`;
485
+ // Fetch newest-first so we always get the most recent N messages.
486
+ // offset > 0 means "load older": skip the N most-recent events we already have.
487
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${pageSize}&offset=${offset}&format=detailed&order_desc=true&app_name=${encodeURIComponent(appName)}`;
477
488
  const response = await fetchWithAutoRefresh(auth, url, {
478
489
  method: "GET",
479
490
  headers: headers(auth),
@@ -483,65 +494,103 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
483
494
  throw new Error(`Failed to load chat history (${response.status}): ${text}`);
484
495
  }
485
496
  const payload = (await response.json());
486
- const messages = [];
487
- for (const [idx, msg] of (payload.chat_history ?? []).entries()) {
488
- const role = msg.author === "user" ? "user" : "assistant";
489
- const parts = parseParts(msg.content);
490
- const ts = msg.timestamp ? new Date(msg.timestamp).getTime() : Date.now();
491
- let text = "";
492
- const workflow = [];
497
+ const raw = payload.chat_history ?? [];
498
+ // If we got a full page there are likely more older events beyond this batch.
499
+ const hasMore = raw.length === pageSize;
500
+ // Events come newest-first from the API — reverse to get chronological order.
501
+ const events = raw.reverse();
502
+ const turns = new Map();
503
+ const turnOrder = [];
504
+ for (const [eIdx, event] of events.entries()) {
505
+ const invId = event.invocation_id ?? `anon-${eIdx}`;
506
+ const ts = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
507
+ if (!turns.has(invId)) {
508
+ turns.set(invId, { userText: "", assistantText: "", workflow: [], segments: [], ts, assistantTs: ts });
509
+ turnOrder.push(invId);
510
+ }
511
+ const turn = turns.get(invId);
512
+ const parts = parseParts(event.content);
493
513
  for (const part of parts) {
514
+ // Thought
494
515
  if (part.thought === true) {
495
516
  if (typeof part.text === "string" && part.text.trim()) {
496
- workflow.push({
497
- id: `hist-th-${idx}-${workflow.length}`,
517
+ const wev = {
518
+ id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
498
519
  type: "thought",
499
520
  content: part.text.trim(),
500
521
  ts,
501
- });
522
+ };
523
+ turn.workflow.push(wev);
524
+ pushWorkflowSegment(turn.segments, wev);
502
525
  }
503
526
  continue;
504
527
  }
528
+ // Tool call
505
529
  const fc = part.functionCall ?? part.function_call;
506
530
  if (fc) {
507
531
  const name = typeof fc.name === "string" ? fc.name : "tool";
508
- const argsStr = fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "";
509
- workflow.push({
510
- id: `hist-fc-${idx}-${name}`,
532
+ const wev = {
533
+ id: `fc-${invId}-${eIdx}-${name}`,
511
534
  type: "function_call",
512
- content: argsStr,
535
+ content: fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "",
513
536
  ts,
514
537
  details: { name, id: fc.id },
515
- });
538
+ };
539
+ turn.workflow.push(wev);
540
+ pushWorkflowSegment(turn.segments, wev);
516
541
  continue;
517
542
  }
543
+ // Tool response (ADK sends these as author="user" — they belong to the assistant bubble)
518
544
  const fr = part.functionResponse ?? part.function_response;
519
545
  if (fr) {
520
546
  const name = typeof fr.name === "string" ? fr.name : "tool";
521
- workflow.push({
522
- id: `hist-fr-${idx}-${name}`,
547
+ const wev = {
548
+ id: `fr-${invId}-${eIdx}-${name}`,
523
549
  type: "function_response",
524
- content: typeof fr.response === "string" ? fr.response : (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
550
+ content: extractFunctionResult(fr.response) || (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
525
551
  ts,
526
552
  details: { name, id: fr.id },
527
- });
553
+ };
554
+ turn.workflow.push(wev);
555
+ pushWorkflowSegment(turn.segments, wev);
528
556
  continue;
529
557
  }
558
+ // Plain text — user bubble if author === "user", otherwise assistant bubble
530
559
  if (typeof part.text === "string" && part.text.trim()) {
531
- text = text ? `${text}\n${part.text}` : part.text;
560
+ if (event.author === "user") {
561
+ turn.userText = turn.userText ? `${turn.userText}\n${part.text}` : part.text;
562
+ }
563
+ else {
564
+ turn.assistantText = turn.assistantText ? `${turn.assistantText}\n${part.text}` : part.text;
565
+ turn.assistantTs = ts;
566
+ pushTextSegment(turn.segments, part.text);
567
+ }
532
568
  }
533
569
  }
534
- if (!text.trim() && workflow.length === 0)
535
- continue;
536
- messages.push({
537
- id: msg.invocation_id ? `${msg.invocation_id}-${idx}` : `hist-${idx}`,
538
- role,
539
- content: text,
540
- ts,
541
- workflow: workflow.length > 0 ? workflow : undefined,
542
- });
543
570
  }
544
- return messages;
571
+ const messages = [];
572
+ for (const invId of turnOrder) {
573
+ const turn = turns.get(invId);
574
+ if (turn.userText.trim()) {
575
+ messages.push({
576
+ id: `${invId}:user`,
577
+ role: "user",
578
+ content: turn.userText,
579
+ ts: turn.ts,
580
+ });
581
+ }
582
+ if (turn.assistantText.trim() || turn.workflow.length > 0) {
583
+ messages.push({
584
+ id: `${invId}:assistant`,
585
+ role: "assistant",
586
+ content: turn.assistantText,
587
+ ts: turn.assistantTs,
588
+ workflow: turn.workflow.length > 0 ? turn.workflow : undefined,
589
+ segments: turn.segments.length > 0 ? turn.segments : undefined,
590
+ });
591
+ }
592
+ }
593
+ return { messages, hasMore };
545
594
  }
546
595
  export async function streamChat(input, callbacks = {}) {
547
596
  const { userId } = ensureAuth(input.auth);
@@ -623,6 +672,8 @@ export async function streamChat(input, callbacks = {}) {
623
672
  }
624
673
  const parts = event.content?.parts ?? [];
625
674
  const isThoughtType = normalizedEventType === "thought" || normalizedEventType === "thoughts";
675
+ // Accumulate all visible text from this event (console pattern: batch per event)
676
+ let visibleChunk = "";
626
677
  for (const part of parts) {
627
678
  const partText = typeof part.text === "string" ? part.text : "";
628
679
  if ((part.thought === true || isThoughtType) && partText.trim()) {
@@ -657,13 +708,18 @@ export async function streamChat(input, callbacks = {}) {
657
708
  }));
658
709
  continue;
659
710
  }
711
+ // Visible text (non-thought): accumulate for batch merge
660
712
  if (partText && part.thought !== true) {
661
- const merged = mergeChunks(accumulatedText, partText);
662
- if (merged !== accumulatedText) {
663
- accumulatedText = merged;
664
- hasContent = accumulatedText.trim().length > 0;
665
- callbacks.onText?.(accumulatedText);
666
- }
713
+ visibleChunk += partText;
714
+ }
715
+ }
716
+ // Merge accumulated visible text once per event (handles partial/cumulative chunks)
717
+ if (visibleChunk) {
718
+ const merged = mergeChunks(accumulatedText, visibleChunk);
719
+ if (merged !== accumulatedText) {
720
+ accumulatedText = merged;
721
+ hasContent = accumulatedText.trim().length > 0;
722
+ callbacks.onText?.(accumulatedText);
667
723
  }
668
724
  }
669
725
  if (parts.length === 0) {
@@ -724,7 +780,9 @@ export async function streamChat(input, callbacks = {}) {
724
780
  }));
725
781
  }
726
782
  }
727
- if (event.text && parts.length === 0) {
783
+ // Merge top-level event.text even when content.parts has workflow events (e.g. function_call).
784
+ // Some events mix workflow parts with top-level text; we were dropping text when parts.length > 0.
785
+ if (typeof event.text === "string" && event.text.trim()) {
728
786
  const merged = mergeChunks(accumulatedText, event.text);
729
787
  if (merged !== accumulatedText) {
730
788
  accumulatedText = merged;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pure utility functions for building and updating MessageSegment arrays.
3
+ *
4
+ * All mutable helpers operate on arrays that the caller owns (history parsing).
5
+ * All immutable helpers return new arrays (streaming state updates).
6
+ */
7
+ // ─── Mutable helpers (history parsing) ──────────────────────────────────────
8
+ /** Append a workflow event, merging into the last workflow segment when possible. */
9
+ export function pushWorkflowSegment(segments, event) {
10
+ const last = segments[segments.length - 1];
11
+ if (last?.kind === "workflow") {
12
+ last.events.push(event);
13
+ }
14
+ else {
15
+ segments.push({ kind: "workflow", events: [event] });
16
+ }
17
+ }
18
+ /** Append text, merging into the last text segment when possible. */
19
+ export function pushTextSegment(segments, text) {
20
+ const trimmed = text.trim();
21
+ if (!trimmed)
22
+ return;
23
+ const last = segments[segments.length - 1];
24
+ if (last?.kind === "text") {
25
+ last.content = `${last.content}\n${trimmed}`;
26
+ }
27
+ else {
28
+ segments.push({ kind: "text", content: trimmed });
29
+ }
30
+ }
31
+ // ─── Immutable helpers (streaming state updates) ─────────────────────────────
32
+ /**
33
+ * Return a new segment list with the workflow event appended.
34
+ * Merges consecutive workflow events into one segment.
35
+ *
36
+ * When a thought arrives after the text (late from stream), prepend it before the
37
+ * text so it appears above the response — better UX when ADK sends thought last.
38
+ */
39
+ export function addWorkflowEvent(segments, event) {
40
+ // Late-arriving thought: segments are [text] only → prepend so thought appears above
41
+ const first = segments[0];
42
+ if (event.type === "thought" &&
43
+ segments.length === 1 &&
44
+ first?.kind === "text" &&
45
+ first.content.trim().length > 0) {
46
+ return [{ kind: "workflow", events: [event] }, first];
47
+ }
48
+ const last = segments[segments.length - 1];
49
+ if (last?.kind === "workflow") {
50
+ return [
51
+ ...segments.slice(0, -1),
52
+ { kind: "workflow", events: [...last.events, event] },
53
+ ];
54
+ }
55
+ return [...segments, { kind: "workflow", events: [event] }];
56
+ }
57
+ /**
58
+ * Return a new segment list where the last workflow segment's final thought is replaced.
59
+ * Used when streaming sends an updated/extended thought.
60
+ */
61
+ export function replaceLastThought(segments, updated) {
62
+ const last = segments[segments.length - 1];
63
+ if (last?.kind !== "workflow")
64
+ return segments;
65
+ const events = last.events.map((e, i) => i === last.events.length - 1 && e.type === "thought" ? updated : e);
66
+ return [...segments.slice(0, -1), { kind: "workflow", events }];
67
+ }
68
+ /**
69
+ * Return a new segment list with the streaming text slice updated.
70
+ * `fullText` is the full accumulated assistant text; `startOffset` marks where the
71
+ * current text segment begins within that string (set each time a workflow event arrives).
72
+ */
73
+ export function updateStreamingText(segments, fullText, startOffset) {
74
+ const slice = fullText.slice(startOffset).trim();
75
+ if (!slice)
76
+ return segments;
77
+ const last = segments[segments.length - 1];
78
+ if (last?.kind === "text") {
79
+ return [...segments.slice(0, -1), { kind: "text", content: slice }];
80
+ }
81
+ return [...segments, { kind: "text", content: slice }];
82
+ }
83
+ /**
84
+ * Merge older segments (from a Ctrl+U load) in front of existing segments.
85
+ * Used when an invocation spans two page-load batches.
86
+ */
87
+ export function mergeOlderSegments(existing, older) {
88
+ if (!older?.length)
89
+ return existing;
90
+ if (!existing?.length)
91
+ return older;
92
+ return [...older, ...existing];
93
+ }