@rubixkube/rubix 0.0.2 → 0.0.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.
@@ -1,4 +1,6 @@
1
1
  import { getConfig } from "../config/env.js";
2
+ import { refreshAccessToken } from "./device-auth.js";
3
+ import { saveAuthConfig } from "./auth-store.js";
2
4
  const DEFAULT_APP_NAME = "SRI Agent";
3
5
  export class StreamError extends Error {
4
6
  reason;
@@ -19,9 +21,38 @@ function opelBase() {
19
21
  }
20
22
  return value.replace(/\/+$/, "");
21
23
  }
24
+ function memoryBase() {
25
+ const value = getConfig().memoryApiBase;
26
+ if (!value) {
27
+ throw new Error("RUBIXKUBE_MEMORY_API_BASE is not set.");
28
+ }
29
+ return value.replace(/\/+$/, "");
30
+ }
31
+ export async function fetchSystemStats(auth) {
32
+ try {
33
+ const url = `${memoryBase()}/api/v1/observability/stats`;
34
+ const response = await fetchWithAutoRefresh(auth, url, {
35
+ method: "GET",
36
+ headers: headers(auth, true),
37
+ });
38
+ if (!response.ok)
39
+ return null;
40
+ const payload = (await response.json());
41
+ return {
42
+ insights: Number(payload.insights ?? 0),
43
+ activeInsights: Number(payload.activeInsights ?? 0),
44
+ resolvedInsights: Number(payload.resolvedInsights ?? 0),
45
+ rcaReports: Number(payload.rcaReports ?? 0),
46
+ lastUpdated: String(payload.lastUpdated ?? new Date().toISOString()),
47
+ };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
22
53
  function ensureAuth(auth) {
23
54
  const token = auth.idToken ?? auth.authToken;
24
- const userId = auth.userId ?? auth.userEmail;
55
+ const userId = auth.userEmail ?? auth.userId;
25
56
  if (!token) {
26
57
  throw new Error("Missing auth token. Run /login.");
27
58
  }
@@ -48,6 +79,38 @@ function headers(auth, includeTenant = true) {
48
79
  }
49
80
  return out;
50
81
  }
82
+ export async function refreshAndUpdateAuth(auth) {
83
+ try {
84
+ const refreshed = await refreshAccessToken(auth);
85
+ await saveAuthConfig(refreshed);
86
+ return refreshed;
87
+ }
88
+ catch (error) {
89
+ const msg = error instanceof Error ? error.message : String(error);
90
+ throw new StreamError(`Token refresh failed: ${msg}. Please run /login again.`, {
91
+ reason: "http_error",
92
+ status: 401,
93
+ });
94
+ }
95
+ }
96
+ async function fetchWithAutoRefresh(auth, url, options) {
97
+ let response = await fetch(url, options);
98
+ if (response.status === 401) {
99
+ try {
100
+ auth = await refreshAndUpdateAuth(auth);
101
+ const newHeaders = { ...options.headers };
102
+ const token = auth.idToken ?? auth.authToken;
103
+ if (token) {
104
+ newHeaders.Authorization = `Bearer ${token}`;
105
+ }
106
+ response = await fetch(url, { ...options, headers: newHeaders });
107
+ }
108
+ catch {
109
+ return response;
110
+ }
111
+ }
112
+ return response;
113
+ }
51
114
  async function parseJsonResponse(response) {
52
115
  if (!response.ok) {
53
116
  const text = await response.text();
@@ -159,6 +222,19 @@ function parseToolNameFromText(value) {
159
222
  return callMatch[1];
160
223
  return "";
161
224
  }
225
+ export async function listApps(auth) {
226
+ const url = `${opelBase()}/apps`;
227
+ const response = await fetchWithAutoRefresh(auth, url, {
228
+ method: "GET",
229
+ headers: headers(auth),
230
+ });
231
+ if (!response.ok) {
232
+ const text = await response.text();
233
+ throw new Error(`Failed to load agents (${response.status}): ${text}`);
234
+ }
235
+ const payload = await parseJsonResponse(response);
236
+ return payload.apps ?? [DEFAULT_APP_NAME];
237
+ }
162
238
  function normalizeWorkflowEvent(type, content, details) {
163
239
  return {
164
240
  id: `${type}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -171,7 +247,7 @@ function normalizeWorkflowEvent(type, content, details) {
171
247
  export async function listSessions(auth, limit = 20, offset = 0) {
172
248
  const { userId } = ensureAuth(auth);
173
249
  const url = `${opelBase()}/sessions/?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=${offset}`;
174
- const response = await fetch(url, {
250
+ const response = await fetchWithAutoRefresh(auth, url, {
175
251
  method: "GET",
176
252
  headers: headers(auth),
177
253
  });
@@ -190,7 +266,7 @@ export async function listSessions(auth, limit = 20, offset = 0) {
190
266
  }))
191
267
  .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
192
268
  }
193
- export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId) {
269
+ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId, modelId) {
194
270
  const { userId, tenantId } = ensureAuth(auth);
195
271
  if (!tenantId) {
196
272
  throw new Error("Missing tenant ID. Run /login.");
@@ -208,11 +284,12 @@ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId)
208
284
  session_created_at: new Date().toISOString(),
209
285
  client_type: "rubix-cli",
210
286
  ...(clusterId ? { cluster_id: clusterId, default_namespace: "default" } : {}),
287
+ ...(modelId ? { model_id: modelId } : {}),
211
288
  ...(auth.userRole ? { user_role: auth.userRole } : {}),
212
289
  ...(auth.tenantPlan ? { tenant_plan: auth.tenantPlan } : {}),
213
290
  },
214
291
  };
215
- const response = await fetch(`${opelBase()}/sessions/`, {
292
+ const response = await fetchWithAutoRefresh(auth, `${opelBase()}/sessions/`, {
216
293
  method: "POST",
217
294
  headers: headers(auth),
218
295
  body: JSON.stringify(payload),
@@ -223,9 +300,9 @@ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId)
223
300
  }
224
301
  return parsed.id;
225
302
  }
226
- export async function updateSessionState(auth, sessionId, state) {
227
- const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(DEFAULT_APP_NAME)}`;
228
- const response = await fetch(url, {
303
+ export async function updateSessionState(auth, sessionId, state, appName = DEFAULT_APP_NAME) {
304
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(appName)}`;
305
+ const response = await fetchWithAutoRefresh(auth, url, {
229
306
  method: "PUT",
230
307
  headers: headers(auth),
231
308
  body: JSON.stringify({ state }),
@@ -235,20 +312,46 @@ export async function updateSessionState(auth, sessionId, state) {
235
312
  throw new Error(`Failed to update session (${response.status}): ${text}`);
236
313
  }
237
314
  }
238
- export async function getOrCreateSession(auth, preferredId, clusterId) {
315
+ export async function hasSessionMessages(auth, sessionId) {
316
+ try {
317
+ const { userId } = ensureAuth(auth);
318
+ // Fetch just 1 history event — cheapest possible check.
319
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=1&offset=0&format=detailed&order_desc=false`;
320
+ const response = await fetchWithAutoRefresh(auth, url, {
321
+ method: "GET",
322
+ headers: headers(auth),
323
+ });
324
+ if (!response.ok)
325
+ return false;
326
+ const payload = (await response.json());
327
+ return (payload.chat_history?.length ?? 0) > 0;
328
+ }
329
+ catch {
330
+ // If we can't determine, assume no messages so we reuse rather than spam new sessions.
331
+ return false;
332
+ }
333
+ }
334
+ export async function getOrCreateSession(auth, preferredId, clusterId, appName = DEFAULT_APP_NAME) {
239
335
  if (preferredId)
240
336
  return preferredId;
241
337
  const sessions = await listSessions(auth, 50, 0);
242
338
  if (sessions.length > 0) {
243
- const recent = sessions[0];
339
+ // Attempt to find the most recent empty session for the same app
340
+ // Note: since list_sessions merges across apps now, we iterate
341
+ // until we find one that matches our appName.
342
+ const recent = sessions.find(s => s.appName === appName) ?? sessions[0];
244
343
  // Reuse the session only if it has matching cluster context (or no cluster was requested)
245
- if (!clusterId || recent.clusterId === clusterId)
246
- return recent.id;
344
+ // AND it has no messages — otherwise start fresh.
345
+ if (!clusterId || recent.clusterId === clusterId) {
346
+ const isEmpty = !(await hasSessionMessages(auth, recent.id));
347
+ if (isEmpty)
348
+ return recent.id;
349
+ }
247
350
  }
248
- return createSession(auth, undefined, clusterId);
351
+ return createSession(auth, appName, clusterId);
249
352
  }
250
353
  const HEALTHY_STATUSES = new Set(["connected", "healthy", "active"]);
251
- export async function listClusters(auth) {
354
+ export async function listEnvironments(auth) {
252
355
  const authBase = getConfig().authApiBase;
253
356
  if (!authBase)
254
357
  throw new Error("RUBIXKUBE_AUTH_API_BASE is not set.");
@@ -257,7 +360,7 @@ export async function listClusters(auth) {
257
360
  throw new Error("Missing tenant ID. Run /login.");
258
361
  }
259
362
  const url = `${authBase.replace(/\/+$/, "")}/clusters/?page=1&page_size=50`;
260
- const response = await fetch(url, {
363
+ const response = await fetchWithAutoRefresh(auth, url, {
261
364
  method: "GET",
262
365
  headers: {
263
366
  Authorization: `Bearer ${token}`,
@@ -267,22 +370,91 @@ export async function listClusters(auth) {
267
370
  });
268
371
  if (!response.ok) {
269
372
  const text = await response.text();
270
- throw new Error(`Failed to load clusters (${response.status}): ${text}`);
373
+ throw new Error(`Failed to load environments (${response.status}): ${text}`);
271
374
  }
272
375
  const payload = (await response.json());
273
376
  return (payload.clusters ?? [])
274
377
  .filter((c) => !!c?.cluster_id)
275
378
  .map((c) => ({
276
379
  id: c.id ?? c.cluster_id ?? "",
277
- cluster_id: c.cluster_id ?? "",
278
- name: c.name ?? c.cluster_id ?? "Unnamed cluster",
380
+ environment_id: c.cluster_id ?? "",
381
+ name: c.name ?? c.cluster_id ?? "Unnamed environment",
279
382
  status: (c.status ?? "unknown"),
280
383
  region: c.region,
281
- cluster_type: c.cluster_type,
384
+ environment_type: c.cluster_type,
282
385
  }));
283
386
  }
284
- export function firstHealthyCluster(clusters) {
285
- return clusters.find((c) => HEALTHY_STATUSES.has(c.status)) ?? clusters[0] ?? null;
387
+ export function firstHealthyEnvironment(environments) {
388
+ return environments.find((c) => HEALTHY_STATUSES.has(c.status)) ?? environments[0] ?? null;
389
+ }
390
+ export async function listModels(auth) {
391
+ const url = `${opelBase()}/models`;
392
+ const response = await fetchWithAutoRefresh(auth, url, {
393
+ method: "GET",
394
+ headers: headers(auth, false),
395
+ });
396
+ if (!response.ok) {
397
+ const text = await response.text();
398
+ throw new StreamError(`Failed to load models (${response.status}): ${text}`, {
399
+ status: response.status,
400
+ reason: "http_error",
401
+ });
402
+ }
403
+ const payload = await parseJsonResponse(response);
404
+ return (payload.models ?? [])
405
+ .filter((m) => !!m?.id)
406
+ .map((m) => ({
407
+ id: m.id ?? "",
408
+ model: m.model ?? "",
409
+ display_name: m.display_name ?? "",
410
+ description: m.description,
411
+ thinking_supported: m.thinking_supported ?? false,
412
+ agent: m.agent ?? "sre_agent",
413
+ default: m.default ?? false,
414
+ experimental: m.experimental,
415
+ }));
416
+ }
417
+ export async function setSessionModel(auth, sessionId, modelId, appName = DEFAULT_APP_NAME) {
418
+ const { userId } = ensureAuth(auth);
419
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/model`;
420
+ const response = await fetchWithAutoRefresh(auth, url, {
421
+ method: "POST",
422
+ headers: headers(auth, true),
423
+ body: JSON.stringify({
424
+ user_id: userId,
425
+ model_id: modelId,
426
+ app_name: appName,
427
+ }),
428
+ });
429
+ if (!response.ok) {
430
+ const text = await response.text();
431
+ let errorMsg = `HTTP ${response.status}: ${text || response.statusText}`;
432
+ try {
433
+ const errorPayload = JSON.parse(text);
434
+ const error = errorPayload.detail?.error ?? "Unknown error";
435
+ const available = errorPayload.detail?.available ?? [];
436
+ if (available.length > 0) {
437
+ errorMsg = `${error}. Available: ${available.join(", ")}`;
438
+ }
439
+ else {
440
+ errorMsg = error;
441
+ }
442
+ }
443
+ catch {
444
+ // Fall back to generic error message
445
+ }
446
+ throw new StreamError(errorMsg, {
447
+ status: response.status,
448
+ reason: "http_error",
449
+ });
450
+ }
451
+ const parsed = await parseJsonResponse(response);
452
+ return {
453
+ modelId: parsed.model_id ?? modelId,
454
+ model: parsed.model ?? "",
455
+ displayName: parsed.display_name ?? "",
456
+ thinkingSupported: parsed.thinking_supported ?? false,
457
+ };
286
458
  }
287
459
  function parseParts(content) {
288
460
  if (!content)
@@ -301,7 +473,7 @@ function parseParts(content) {
301
473
  export async function fetchChatHistory(auth, sessionId, limit = 50) {
302
474
  const { userId } = ensureAuth(auth);
303
475
  const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=0&format=detailed&order_desc=false`;
304
- const response = await fetch(url, {
476
+ const response = await fetchWithAutoRefresh(auth, url, {
305
477
  method: "GET",
306
478
  headers: headers(auth),
307
479
  });
@@ -318,15 +490,25 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
318
490
  let text = "";
319
491
  const workflow = [];
320
492
  for (const part of parts) {
321
- if (part.thought === true)
493
+ if (part.thought === true) {
494
+ if (typeof part.text === "string" && part.text.trim()) {
495
+ workflow.push({
496
+ id: `hist-th-${idx}-${workflow.length}`,
497
+ type: "thought",
498
+ content: part.text.trim(),
499
+ ts,
500
+ });
501
+ }
322
502
  continue;
503
+ }
323
504
  const fc = part.functionCall ?? part.function_call;
324
505
  if (fc) {
325
506
  const name = typeof fc.name === "string" ? fc.name : "tool";
507
+ const argsStr = fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "";
326
508
  workflow.push({
327
509
  id: `hist-fc-${idx}-${name}`,
328
510
  type: "function_call",
329
- content: name,
511
+ content: argsStr,
330
512
  ts,
331
513
  details: { name, id: fc.id },
332
514
  });
@@ -338,7 +520,7 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
338
520
  workflow.push({
339
521
  id: `hist-fr-${idx}-${name}`,
340
522
  type: "function_response",
341
- content: typeof fr.response === "string" ? fr.response : `[${name}]`,
523
+ content: typeof fr.response === "string" ? fr.response : (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
342
524
  ts,
343
525
  details: { name, id: fr.id },
344
526
  });
@@ -362,7 +544,7 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
362
544
  }
363
545
  export async function streamChat(input, callbacks = {}) {
364
546
  const { userId } = ensureAuth(input.auth);
365
- const response = await fetch(`${opelBase()}/chat/${encodeURIComponent(userId)}/session/${encodeURIComponent(input.sessionId)}`, {
547
+ const response = await fetchWithAutoRefresh(input.auth, `${opelBase()}/chat/${encodeURIComponent(userId)}/session/${encodeURIComponent(input.sessionId)}`, {
366
548
  method: "POST",
367
549
  headers: headers(input.auth, true),
368
550
  signal: input.signal,
@@ -376,6 +558,8 @@ export async function streamChat(input, callbacks = {}) {
376
558
  streaming: true,
377
559
  minify: true,
378
560
  maxTextLen: -1,
561
+ ...(input.stateDelta ? { stateDelta: input.stateDelta } : {}),
562
+ ...(input.modelOverride ? { modelOverride: input.modelOverride } : {}),
379
563
  }),
380
564
  });
381
565
  if (!response.ok) {
@@ -453,8 +637,8 @@ export async function streamChat(input, callbacks = {}) {
453
637
  hasWorkflowEvents = true;
454
638
  const name = asText(functionCall.name) || "tool";
455
639
  const args = functionCall.args ?? {};
456
- const prettyArgs = Object.keys(args).length > 0 ? `: ${JSON.stringify(args)}` : "";
457
- callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", `${name}${prettyArgs}`, {
640
+ const argsStr = Object.keys(args).length > 0 ? JSON.stringify(args) : "";
641
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", argsStr, {
458
642
  name,
459
643
  id: functionCall.id,
460
644
  }));
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CONFIG_DIR = ".rubix";
5
+ const SESSION_FILE = "sessions.json";
6
+ export function getSessionPath() {
7
+ return path.join(os.homedir(), CONFIG_DIR, SESSION_FILE);
8
+ }
9
+ export async function loadLocalSessions() {
10
+ try {
11
+ const data = await fs.readFile(getSessionPath(), "utf8");
12
+ return JSON.parse(data);
13
+ }
14
+ catch (error) {
15
+ const asNodeError = error;
16
+ if (asNodeError.code === "ENOENT") {
17
+ return [];
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ export async function saveLocalSessions(sessions) {
23
+ await fs.mkdir(path.join(os.homedir(), CONFIG_DIR), { recursive: true, mode: 0o700 });
24
+ await fs.writeFile(getSessionPath(), JSON.stringify(sessions, null, 2), { mode: 0o600 });
25
+ }
26
+ export async function clearLocalSessions() {
27
+ try {
28
+ await fs.unlink(getSessionPath());
29
+ }
30
+ catch (error) {
31
+ const asNodeError = error;
32
+ if (asNodeError.code !== "ENOENT") {
33
+ throw error;
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CONFIG_DIR = ".rubix";
5
+ const SETTINGS_FILE = "settings.json";
6
+ export function getSettingsPath() {
7
+ return path.join(os.homedir(), CONFIG_DIR, SETTINGS_FILE);
8
+ }
9
+ export async function loadSettings() {
10
+ try {
11
+ const data = await fs.readFile(getSettingsPath(), "utf8");
12
+ const raw = JSON.parse(data);
13
+ if (raw.clusterId && !raw.environmentId) {
14
+ raw.environmentId = raw.clusterId;
15
+ delete raw.clusterId;
16
+ }
17
+ return raw;
18
+ }
19
+ catch (error) {
20
+ const asNodeError = error;
21
+ if (asNodeError.code === "ENOENT") {
22
+ return {};
23
+ }
24
+ throw error;
25
+ }
26
+ }
27
+ export async function saveSettings(settings) {
28
+ await fs.mkdir(path.join(os.homedir(), CONFIG_DIR), { recursive: true, mode: 0o700 });
29
+ await fs.writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), { mode: 0o600 });
30
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Simple semver-ish comparison. Returns true if latest is strictly greater than current.
3
+ * Handles major.minor.patch format.
4
+ */
5
+ function isNewer(latest, current) {
6
+ const l = latest.split(".").map(Number);
7
+ const c = current.split(".").map(Number);
8
+ for (let i = 0; i < 3; i++) {
9
+ const lv = l[i] ?? 0;
10
+ const cv = c[i] ?? 0;
11
+ if (lv > cv)
12
+ return true;
13
+ if (lv < cv)
14
+ return false;
15
+ }
16
+ return false;
17
+ }
18
+ /**
19
+ * Check if a new version of the CLI is available on npm.
20
+ *
21
+ * To maintain performance, this should only be called once every 24 hours.
22
+ * Returns the latest version string if a new version is available, otherwise null.
23
+ */
24
+ export async function checkForUpdate(currentVersion, lastCheckTime) {
25
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
26
+ const now = Date.now();
27
+ // Only check once every 24 hours
28
+ if (lastCheckTime && now - lastCheckTime < ONE_DAY_MS) {
29
+ return null;
30
+ }
31
+ try {
32
+ const controller = new AbortController();
33
+ const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
34
+ const response = await fetch("https://registry.npmjs.org/@rubixkube/rubix/latest", {
35
+ signal: controller.signal,
36
+ });
37
+ clearTimeout(timeoutId);
38
+ if (!response.ok)
39
+ return null;
40
+ const data = (await response.json());
41
+ const latestVersion = data.version;
42
+ if (latestVersion && isNewer(latestVersion, currentVersion)) {
43
+ return latestVersion;
44
+ }
45
+ return null;
46
+ }
47
+ catch {
48
+ // Fail silently — never block startup or show errors for update checks.
49
+ return null;
50
+ }
51
+ }
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ /**
6
+ * Default items shown when CHANGELOG.md cannot be read or parsed.
7
+ */
8
+ const FALLBACK = [];
9
+ /**
10
+ * Parse the `## [<version>]` section from CHANGELOG.md and return its bullet items.
11
+ *
12
+ * Looks for lines matching `- <text>` under the first matching version heading.
13
+ * Stops when it hits the next version heading (`## [`) or end-of-file.
14
+ *
15
+ * Returns an empty array on any error so the splash screen always renders.
16
+ */
17
+ export function loadWhatsNew(version, maxItems = 4) {
18
+ try {
19
+ const changelogPath = join(__dirname, "..", "..", "CHANGELOG.md");
20
+ const raw = readFileSync(changelogPath, "utf-8");
21
+ return parseWhatsNew(raw, version).slice(0, maxItems);
22
+ }
23
+ catch {
24
+ return FALLBACK;
25
+ }
26
+ }
27
+ /**
28
+ * Pure parser extracted for testability.
29
+ */
30
+ export function parseWhatsNew(markdown, version) {
31
+ const lines = markdown.split("\n");
32
+ // Find the heading for the requested version: ## [0.0.3] or ## [0.0.3] - 2026-03-01
33
+ const versionHeading = `## [${version}]`;
34
+ let startIndex = -1;
35
+ for (let i = 0; i < lines.length; i++) {
36
+ if (lines[i].startsWith(versionHeading)) {
37
+ startIndex = i + 1;
38
+ break;
39
+ }
40
+ }
41
+ if (startIndex === -1)
42
+ return FALLBACK;
43
+ const items = [];
44
+ for (let i = startIndex; i < lines.length; i++) {
45
+ const line = lines[i];
46
+ // Stop at the next version heading
47
+ if (line.startsWith("## ["))
48
+ break;
49
+ // Collect bullet items (- text)
50
+ const match = line.match(/^[-*]\s+(.+)/);
51
+ if (match) {
52
+ items.push(match[1].trim());
53
+ }
54
+ }
55
+ return items;
56
+ }