@circuitwall/jarela 2.4.0 → 2.5.0

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 (140) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -1
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +1 -1
  19. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +1 -1
  20. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +1 -1
  21. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +1 -1
  23. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/events/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  33. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  35. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  36. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  37. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  38. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  39. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +578 -58
  40. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  41. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +118 -26
  42. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -1
  43. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  47. package/.next/standalone/.next/server/app/page.js +31202 -30960
  48. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app-paths-manifest.json +1 -1
  51. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  52. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  53. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  54. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  55. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  56. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  57. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  58. package/.next/standalone/.next/server/chunks/46.js +18 -3
  59. package/.next/standalone/.next/server/chunks/466.js +53 -7
  60. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  61. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  62. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9164.js +65 -14
  64. package/.next/standalone/.next/server/chunks/9590.js +18 -3
  65. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  66. package/.next/standalone/.next/server/middleware.js +18 -3
  67. package/.next/standalone/.next/server/pages/404.html +14 -2
  68. package/.next/standalone/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/.next/server/preview-props.json +3 -3
  70. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  71. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  72. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  73. package/.next/standalone/.next/static/chunks/app/{page-04748847c6ff866f.js → page-2ef8d75d40eb4311.js} +10661 -10352
  74. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  75. package/.next/standalone/docs/adoption/changesets.json +2756 -0
  76. package/.next/standalone/package.json +1 -1
  77. package/CHANGELOG.md +33 -0
  78. package/api/client.models.test.ts +10 -0
  79. package/api/client.startup.test.ts +129 -0
  80. package/api/client.ts +51 -9
  81. package/api/thread-metadata.test.ts +72 -0
  82. package/api/types.ts +6 -3
  83. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  84. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  85. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  86. package/app/globals.css +119 -29
  87. package/app/layout.tsx +12 -0
  88. package/components/agents/AgentsPanel.tsx +5 -13
  89. package/components/chat/ChatView.tsx +9 -18
  90. package/components/chat/InputBar.tsx +1 -1
  91. package/components/chat/MessageBubble.tsx +1 -1
  92. package/components/chat/MessageList.test.tsx +45 -4
  93. package/components/chat/MessageList.tsx +123 -89
  94. package/components/chat/ToolList.tsx +88 -84
  95. package/components/chat/useThreadActivity.test.tsx +2 -2
  96. package/components/chat/useThreadActivity.ts +2 -2
  97. package/components/chat/useThreadData.ts +1 -30
  98. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  99. package/components/chat/useThreadMetadataPolling.ts +50 -0
  100. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  101. package/components/chat/useUserProfileAndAgent.ts +13 -2
  102. package/components/dashboard/DashboardPanel.tsx +1 -1
  103. package/components/dashboard/Series.tsx +34 -21
  104. package/components/dashboard/SummaryCards.tsx +1 -1
  105. package/components/layout/AppShell.tsx +22 -7
  106. package/components/layout/MenuPanel.tsx +14 -24
  107. package/components/profile/ProfilePanel.tsx +2 -2
  108. package/components/settings/AppearancePanel.tsx +70 -3
  109. package/components/settings/SettingsPanel.tsx +2 -2
  110. package/components/tools/ToolsPanel.tsx +2 -7
  111. package/components/ui/AppearancePreferences.test.tsx +68 -0
  112. package/components/ui/BootScreen.test.tsx +44 -0
  113. package/components/ui/BootScreen.tsx +24 -11
  114. package/components/ui/NotificationStatus.test.tsx +2 -2
  115. package/components/ui/NotificationStatus.tsx +5 -5
  116. package/components/ui/SubTabBar.tsx +2 -3
  117. package/contexts/ThemeContext.tsx +84 -1
  118. package/hooks/useAgentSession.test.ts +12 -1
  119. package/hooks/useAgentSession.ts +6 -4
  120. package/hooks/useUrlSync.test.ts +13 -0
  121. package/hooks/useUrlSync.ts +0 -4
  122. package/lib/db/migrations.test.ts +61 -0
  123. package/lib/db/migrations.ts +13 -0
  124. package/lib/embeddings/performance.test.ts +114 -0
  125. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  126. package/lib/stores/dashboard-metrics.ts +30 -4
  127. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  128. package/lib/stores/message-embedding-jobs.ts +5 -3
  129. package/lib/stores/message-usage.test.ts +21 -1
  130. package/lib/stores/pricing-cache.test.ts +31 -0
  131. package/lib/stores/pricing.ts +3 -0
  132. package/lib/tools/packages/default-packages.test.ts +21 -0
  133. package/lib/tools/packages/optional-package.test.ts +72 -0
  134. package/lib/tools/packages/optional-package.ts +33 -4
  135. package/lib/ui/navigate.test.ts +13 -0
  136. package/lib/ui/navigate.ts +10 -2
  137. package/package.json +1 -1
  138. package/.next/standalone/.next/static/css/2669e8744cc8c212.css +0 -5
  139. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  140. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -2,13 +2,25 @@
2
2
  import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
3
3
 
4
4
  export type Theme = "light" | "dark" | "system";
5
+ export type AccentColor = "default" | "blue" | "teal" | "violet" | "rose";
6
+ export type SurfaceStyle = "ambient" | "calm" | "liquid";
5
7
 
6
8
  const STORAGE_KEY = "jarela-theme";
9
+ const ACCENT_STORAGE_KEY = "jarela-accent-color";
10
+ const SURFACE_STORAGE_KEY = "jarela-surface-style";
7
11
 
8
12
  function isTheme(v: unknown): v is Theme {
9
13
  return v === "light" || v === "dark" || v === "system";
10
14
  }
11
15
 
16
+ function isAccentColor(v: unknown): v is AccentColor {
17
+ return v === "default" || v === "blue" || v === "teal" || v === "violet" || v === "rose";
18
+ }
19
+
20
+ function isSurfaceStyle(v: unknown): v is SurfaceStyle {
21
+ return v === "ambient" || v === "calm" || v === "liquid";
22
+ }
23
+
12
24
  function readStored(): Theme {
13
25
  if (typeof window === "undefined") return "system";
14
26
  try {
@@ -19,6 +31,26 @@ function readStored(): Theme {
19
31
  }
20
32
  }
21
33
 
34
+ function readStoredAccent(): AccentColor {
35
+ if (typeof window === "undefined") return "blue";
36
+ try {
37
+ const value = window.localStorage.getItem(ACCENT_STORAGE_KEY);
38
+ return isAccentColor(value) ? value : "default";
39
+ } catch {
40
+ return "default";
41
+ }
42
+ }
43
+
44
+ function readStoredSurface(): SurfaceStyle {
45
+ if (typeof window === "undefined") return "ambient";
46
+ try {
47
+ const value = window.localStorage.getItem(SURFACE_STORAGE_KEY);
48
+ return isSurfaceStyle(value) ? value : "ambient";
49
+ } catch {
50
+ return "ambient";
51
+ }
52
+ }
53
+
22
54
  const LIGHT_CHROME = "#ffffff";
23
55
  const DARK_CHROME = "#09090b";
24
56
 
@@ -44,9 +76,27 @@ function apply(theme: Theme) {
44
76
  syncChrome(theme);
45
77
  }
46
78
 
79
+ function applyAccentColor(accentColor: AccentColor) {
80
+ if (typeof document === "undefined") return;
81
+ if (accentColor === "default") {
82
+ document.documentElement.removeAttribute("data-accent-color");
83
+ return;
84
+ }
85
+ document.documentElement.setAttribute("data-accent-color", accentColor);
86
+ }
87
+
88
+ function applySurfaceStyle(surfaceStyle: SurfaceStyle) {
89
+ if (typeof document === "undefined") return;
90
+ document.documentElement.setAttribute("data-surface-style", surfaceStyle);
91
+ }
92
+
47
93
  interface Ctx {
48
94
  theme: Theme;
49
95
  setTheme: (t: Theme) => void;
96
+ accentColor: AccentColor;
97
+ setAccentColor: (color: AccentColor) => void;
98
+ surfaceStyle: SurfaceStyle;
99
+ setSurfaceStyle: (style: SurfaceStyle) => void;
50
100
  }
51
101
 
52
102
  const ThemeContext = createContext<Ctx | null>(null);
@@ -56,10 +106,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
56
106
  // which writes data-theme before React hydrates. The effect below syncs the
57
107
  // React state to whatever the script (or localStorage) decided.
58
108
  const [theme, setThemeState] = useState<Theme>("system");
109
+ const [accentColor, setAccentColorState] = useState<AccentColor>("blue");
110
+ const [surfaceStyle, setSurfaceStyleState] = useState<SurfaceStyle>("ambient");
59
111
 
60
112
  useEffect(() => {
61
113
  const stored = readStored();
114
+ const storedAccent = readStoredAccent();
115
+ const storedSurface = readStoredSurface();
62
116
  setThemeState(stored);
117
+ setAccentColorState(storedAccent);
118
+ setSurfaceStyleState(storedSurface);
119
+ applyAccentColor(storedAccent);
120
+ applySurfaceStyle(storedSurface);
63
121
  // When in "system" mode, mirror OS-level changes into the PWA chrome.
64
122
  if (typeof window === "undefined") return;
65
123
  const mq = window.matchMedia("(prefers-color-scheme: dark)");
@@ -80,7 +138,32 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
80
138
  }
81
139
  }, []);
82
140
 
83
- return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
141
+ const setAccentColor = useCallback((color: AccentColor) => {
142
+ setAccentColorState(color);
143
+ applyAccentColor(color);
144
+ try {
145
+ if (color === "default") window.localStorage.removeItem(ACCENT_STORAGE_KEY);
146
+ else window.localStorage.setItem(ACCENT_STORAGE_KEY, color);
147
+ } catch {
148
+ /* ignore quota / private-mode errors */
149
+ }
150
+ }, []);
151
+
152
+ const setSurfaceStyle = useCallback((style: SurfaceStyle) => {
153
+ setSurfaceStyleState(style);
154
+ applySurfaceStyle(style);
155
+ try {
156
+ window.localStorage.setItem(SURFACE_STORAGE_KEY, style);
157
+ } catch {
158
+ /* ignore quota / private-mode errors */
159
+ }
160
+ }, []);
161
+
162
+ return (
163
+ <ThemeContext.Provider value={{ theme, setTheme, accentColor, setAccentColor, surfaceStyle, setSurfaceStyle }}>
164
+ {children}
165
+ </ThemeContext.Provider>
166
+ );
84
167
  }
85
168
 
86
169
  export function useTheme() {
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { renderHook, waitFor } from "@testing-library/react";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { useAgentSession } from "@/hooks/useAgentSession";
6
6
 
@@ -11,6 +11,7 @@ vi.mock("@/api/client", () => ({
11
11
  api: {
12
12
  threads: {
13
13
  get: (...args: unknown[]) => threadGetMock(...args),
14
+ metadata: (...args: unknown[]) => threadGetMock(...args),
14
15
  },
15
16
  agents: {
16
17
  getThread: (...args: unknown[]) => agentGetThreadMock(...args),
@@ -35,5 +36,15 @@ describe("useAgentSession contract", () => {
35
36
  expect(result.current.loading).toBe(result.current.state.loading);
36
37
  expect(result.current.refresh).toBe(result.current.commands.refresh);
37
38
  expect(agentGetThreadMock).not.toHaveBeenCalled();
39
+ expect(threadGetMock).toHaveBeenCalledWith("preferred-thread", { startup: true });
40
+ });
41
+
42
+ it("uses the startup handoff initially but keeps explicit refreshes fresh", async () => {
43
+ agentGetThreadMock.mockResolvedValue({ thread_id: "thread" });
44
+ const { result } = renderHook(() => useAgentSession("agent"));
45
+ await waitFor(() => expect(result.current.threadId).toBe("thread"));
46
+ expect(agentGetThreadMock).toHaveBeenLastCalledWith("agent", { startup: true });
47
+ await act(async () => { await result.current.refresh(); });
48
+ expect(agentGetThreadMock).toHaveBeenLastCalledWith("agent", undefined);
38
49
  });
39
50
  });
@@ -15,7 +15,7 @@ export function useAgentSession(
15
15
  const [error, setError] = useState<string | null>(null);
16
16
  const requestSeqRef = useRef(0);
17
17
 
18
- const refresh = useCallback(async () => {
18
+ const refresh = useCallback(async (opts?: { startup?: boolean }) => {
19
19
  if (!agentId) {
20
20
  setThreadId(null);
21
21
  setError(null);
@@ -32,7 +32,9 @@ export function useAgentSession(
32
32
  // prefer it as long as it still belongs to this agent.
33
33
  if (preferredThreadId) {
34
34
  try {
35
- const d = await api.threads.get(preferredThreadId, { limit: 1 });
35
+ const d = opts?.startup
36
+ ? await api.threads.get(preferredThreadId, { startup: true })
37
+ : await api.threads.metadata(preferredThreadId);
36
38
  if (mySeq !== requestSeqRef.current) return;
37
39
  if (d.agent_id === agentId) {
38
40
  setThreadId(preferredThreadId);
@@ -43,7 +45,7 @@ export function useAgentSession(
43
45
  }
44
46
  }
45
47
 
46
- const t = await api.agents.getThread(agentId);
48
+ const t = await api.agents.getThread(agentId, opts);
47
49
  if (mySeq !== requestSeqRef.current) return;
48
50
  setThreadId(t.thread_id);
49
51
  } catch (err) {
@@ -70,7 +72,7 @@ export function useAgentSession(
70
72
  return;
71
73
  }
72
74
 
73
- void refresh();
75
+ void refresh({ startup: true });
74
76
 
75
77
  }, [agentId, preferredThreadId, refresh]);
76
78
 
@@ -30,4 +30,17 @@ describe("useUrlSync contract", () => {
30
30
  replaceStateSpy.mockRestore();
31
31
  });
32
32
  });
33
+
34
+ it.each([
35
+ "/?tab=documents",
36
+ "/?tab=tools&item=documents",
37
+ "/?tab=tools&item=memory",
38
+ ])("routes legacy Knowledge URL %s to the Advanced Knowledge panel", async (url) => {
39
+ window.history.replaceState(null, "", url);
40
+ renderHook(() => useUrlSync(), { wrapper });
41
+
42
+ await waitFor(() => {
43
+ expect(window.location.search).toBe("?tab=memory");
44
+ });
45
+ });
33
46
  });
@@ -38,10 +38,6 @@ export function useUrlSync(): UnifiedHookResult<
38
38
  tab = "tools";
39
39
  item = "packages";
40
40
  }
41
- if (tab === "documents" || tab === "memory") {
42
- tab = "tools";
43
- item = "memory";
44
- }
45
41
  // Browser-extension "Open Jarela" passes ?agent=<id> (and optionally
46
42
  // ?thread=<id>) so the app lands on the user's currently-picked
47
43
  // target agent instead of whichever chat was last open. The agent
@@ -18,6 +18,67 @@ afterAll(() => {
18
18
  try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
19
19
  });
20
20
 
21
+ describe("responsive transcript indexes (ADR-0103)", () => {
22
+ it("restores missing indexes on upgrade and remains idempotent", async () => {
23
+ const db = getDb();
24
+ const names = [
25
+ "idx_messages_thread_seq", "idx_messages_created_at",
26
+ "idx_threads_updated", "idx_message_usage_thread_created",
27
+ "idx_memory_namespace_updated", "idx_memory_updated",
28
+ "idx_runtime_operations_thread_state", "idx_signal_events_thread",
29
+ "idx_signal_events_operation", "idx_signal_deliveries_lease",
30
+ "idx_background_results_expiry", "idx_message_embedding_jobs_lease",
31
+ ];
32
+ for (const name of names) db.exec(`DROP INDEX IF EXISTS ${name}`);
33
+ const { runMigrations } = await import("./migrations");
34
+ runMigrations(db);
35
+ runMigrations(db);
36
+ const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all()
37
+ .map((row) => row.name);
38
+ expect(indexes).toEqual(expect.arrayContaining(names));
39
+ });
40
+
41
+ it.each([
42
+ ["SELECT rowid, msg_id, content FROM messages WHERE thread_id=? ORDER BY rowid DESC LIMIT ?", ["thread", 51]],
43
+ ["SELECT rowid, msg_id, content FROM messages WHERE thread_id=? AND rowid<? ORDER BY rowid DESC LIMIT ?", ["thread", 100, 51]],
44
+ ["SELECT rowid, msg_id, content FROM messages WHERE thread_id=? AND rowid>? ORDER BY rowid ASC LIMIT ?", ["thread", 100, 50]],
45
+ ["SELECT content FROM messages WHERE created_at>=? ORDER BY created_at ASC", ["2026-10-01"]],
46
+ ["SELECT * FROM threads WHERE agent_id=? ORDER BY updated_at DESC LIMIT ?", ["agent", 50]],
47
+ ["SELECT * FROM threads ORDER BY updated_at DESC LIMIT ? OFFSET ?", [50, 0]],
48
+ ])("uses ordered indexes for %s", (query, params) => {
49
+ const plan = getDb().prepare(`EXPLAIN QUERY PLAN ${query}`).all(...params)
50
+ .map((row) => String(row.detail)).join("; ");
51
+ expect(plan).not.toMatch(/USE TEMP B-TREE|SCAN (?:messages|threads)(?! USING INDEX)/);
52
+ expect(plan).toMatch(/USING INDEX/);
53
+ });
54
+
55
+ it.each([
56
+ ["SELECT * FROM message_usage WHERE thread_id=? ORDER BY created_at DESC LIMIT 1", ["thread"], "idx_message_usage_thread_created"],
57
+ ["SELECT namespace,key,value FROM memory_store WHERE namespace=? ORDER BY updated_at DESC LIMIT ?", ["facts", 50], "idx_memory_namespace_updated"],
58
+ ["SELECT namespace,key,value FROM memory_store ORDER BY updated_at DESC LIMIT ?", [50], "idx_memory_updated"],
59
+ ["SELECT rowid FROM runtime_operations WHERE thread_id=? AND state='accepted'", ["thread"], "idx_runtime_operations_thread_state"],
60
+ ["SELECT rowid FROM signal_events WHERE thread_id=?", ["thread"], "idx_signal_events_thread"],
61
+ ["SELECT rowid FROM signal_events WHERE operation_id=?", ["operation"], "idx_signal_events_operation"],
62
+ ["SELECT key FROM background_tool_results WHERE expires_at<=?", [1000], "idx_background_results_expiry"],
63
+ ["SELECT event_id FROM signal_deliveries WHERE state='leased' AND lease_until<=?", [1000], "idx_signal_deliveries_lease"],
64
+ ["SELECT message_id FROM message_embedding_jobs WHERE state='processing' AND lease_until_ms<=?", [1000], "idx_message_embedding_jobs_lease"],
65
+ ])("indexes secondary lookup %s", (query, params, index) => {
66
+ const plan = getDb().prepare(`EXPLAIN QUERY PLAN ${query}`).all(...params)
67
+ .map((row) => String(row.detail)).join("; ");
68
+ expect(plan).not.toMatch(/USE TEMP B-TREE/);
69
+ expect(plan).toContain(index);
70
+ if (!query.includes("FROM memory_store ORDER BY")) expect(plan).toContain("SEARCH");
71
+ });
72
+
73
+ it("reuses the unique agent index instead of adding a redundant ordering index", () => {
74
+ const indexes = getDb().prepare("PRAGMA index_list(threads)").all();
75
+ expect(indexes).toEqual(expect.arrayContaining([
76
+ expect.objectContaining({ name: "idx_threads_agent_id", unique: 1 }),
77
+ ]));
78
+ expect(indexes.some((row) => row.name === "idx_threads_agent_updated")).toBe(false);
79
+ });
80
+ });
81
+
21
82
  describe("warm summary limit migration", () => {
22
83
  it("adds the default limit to existing agents", async () => {
23
84
  const db = getDb();
@@ -35,6 +35,9 @@ export function runMigrations(db: DatabaseSync): void {
35
35
  -- (getMessages, getRecentMessagesWindow, getMessagesPage, clear).
36
36
  -- Without this every read full-scans the messages table.
37
37
  CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, created_at);
38
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_seq ON messages(thread_id);
39
+ CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
40
+ CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC);
38
41
  CREATE TABLE IF NOT EXISTS runtime_operations (
39
42
  id TEXT PRIMARY KEY,
40
43
  kind TEXT NOT NULL,
@@ -58,6 +61,9 @@ export function runMigrations(db: DatabaseSync): void {
58
61
  created_at INTEGER NOT NULL,
59
62
  UNIQUE(kind,operation_id,agent_id,thread_id)
60
63
  );
64
+ CREATE INDEX IF NOT EXISTS idx_runtime_operations_thread_state ON runtime_operations(thread_id,state);
65
+ CREATE INDEX IF NOT EXISTS idx_signal_events_thread ON signal_events(thread_id);
66
+ CREATE INDEX IF NOT EXISTS idx_signal_events_operation ON signal_events(operation_id);
61
67
  CREATE TABLE IF NOT EXISTS signal_deliveries (
62
68
  event_id TEXT PRIMARY KEY REFERENCES signal_events(id) ON DELETE CASCADE,
63
69
  wake_eligible INTEGER NOT NULL DEFAULT 0,
@@ -72,6 +78,7 @@ export function runMigrations(db: DatabaseSync): void {
72
78
  );
73
79
  CREATE INDEX IF NOT EXISTS idx_signal_events_target ON signal_events(agent_id,thread_id,seq);
74
80
  CREATE INDEX IF NOT EXISTS idx_signal_deliveries_due ON signal_deliveries(state,available_at);
81
+ CREATE INDEX IF NOT EXISTS idx_signal_deliveries_lease ON signal_deliveries(state,lease_until);
75
82
  CREATE TABLE IF NOT EXISTS background_tool_results (
76
83
  key TEXT PRIMARY KEY REFERENCES runtime_operations(id) ON DELETE CASCADE,
77
84
  thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE,
@@ -83,6 +90,7 @@ export function runMigrations(db: DatabaseSync): void {
83
90
  expires_at INTEGER NOT NULL
84
91
  );
85
92
  CREATE INDEX IF NOT EXISTS idx_background_results_owner ON background_tool_results(thread_id,expires_at);
93
+ CREATE INDEX IF NOT EXISTS idx_background_results_expiry ON background_tool_results(expires_at);
86
94
  CREATE TABLE IF NOT EXISTS memory_store (
87
95
  namespace TEXT NOT NULL,
88
96
  key TEXT NOT NULL,
@@ -91,6 +99,8 @@ export function runMigrations(db: DatabaseSync): void {
91
99
  updated_at TEXT NOT NULL,
92
100
  PRIMARY KEY (namespace, key)
93
101
  );
102
+ CREATE INDEX IF NOT EXISTS idx_memory_namespace_updated ON memory_store(namespace,updated_at DESC);
103
+ CREATE INDEX IF NOT EXISTS idx_memory_updated ON memory_store(updated_at DESC);
94
104
  CREATE TABLE IF NOT EXISTS model_configs (
95
105
  name TEXT PRIMARY KEY,
96
106
  provider TEXT NOT NULL,
@@ -660,6 +670,7 @@ function ensureMessageUsageTable(db: DatabaseSync): void {
660
670
  );
661
671
  CREATE INDEX IF NOT EXISTS idx_message_usage_created_at ON message_usage(created_at);
662
672
  CREATE INDEX IF NOT EXISTS idx_message_usage_agent_id ON message_usage(agent_id);
673
+ CREATE INDEX IF NOT EXISTS idx_message_usage_thread_created ON message_usage(thread_id,created_at DESC);
663
674
  `);
664
675
  }
665
676
 
@@ -703,6 +714,8 @@ function ensureMessageEmbeddingJobsTable(db: DatabaseSync): void {
703
714
  );
704
715
  CREATE INDEX IF NOT EXISTS idx_message_embedding_jobs_due
705
716
  ON message_embedding_jobs(state, available_at_ms, lease_until_ms);
717
+ CREATE INDEX IF NOT EXISTS idx_message_embedding_jobs_lease
718
+ ON message_embedding_jobs(state, lease_until_ms);
706
719
  INSERT OR IGNORE INTO message_embedding_jobs (message_id, created_at, updated_at)
707
720
  SELECT msg_id, created_at, created_at FROM messages WHERE embedding IS NULL;
708
721
  `);
@@ -0,0 +1,114 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { performance } from "node:perf_hooks";
3
+ import { afterAll, expect, it, vi } from "vitest";
4
+ import { LOCAL_EMBEDDING_MODEL_ID } from "./constants";
5
+
6
+ // Opt-in, local-only benchmark. No user database, credentials, or downloads.
7
+ const state = vi.hoisted(() => ({ db: null as DatabaseSync | null }));
8
+ vi.mock("@/lib/db", () => ({
9
+ getDb: () => {
10
+ if (!state.db) throw new Error("benchmark database not initialized");
11
+ return state.db;
12
+ },
13
+ }));
14
+ vi.mock("@/lib/providers", () => ({
15
+ getProvider: () => { throw new Error("remote providers are forbidden in this benchmark"); },
16
+ }));
17
+ vi.mock("@/lib/stores/model-config", () => ({
18
+ getModelConfig: () => null,
19
+ getDefaultModelConfig: () => null,
20
+ getModelParams: () => ({}),
21
+ listModelConfigs: () => [],
22
+ }));
23
+
24
+ const previousDownload = process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD;
25
+ afterAll(() => {
26
+ state.db?.close();
27
+ if (previousDownload === undefined) delete process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD;
28
+ else process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD = previousDownload;
29
+ });
30
+
31
+ it.skipIf(process.env.JARELA_BENCHMARK_EMBEDDINGS !== "1")(
32
+ "reports local embedding and synthetic memory-search latency",
33
+ async () => {
34
+ process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD = "0";
35
+ const { getLocalModelStatus } = await import("./local-model");
36
+ expect(getLocalModelStatus().state, "Install the local model before running this offline benchmark").toBe("ready");
37
+ const { runMigrations } = await import("@/lib/db/migrations");
38
+ state.db = new DatabaseSync(":memory:");
39
+ runMigrations(state.db);
40
+ const { setLocalEmbeddingsEnabled, setEmbeddingVectorsSignature } = await import("@/lib/stores/app-settings");
41
+ setLocalEmbeddingsEnabled(true);
42
+ setEmbeddingVectorsSignature(LOCAL_EMBEDDING_MODEL_ID);
43
+ const { embedLocally } = await import("./local");
44
+ const { embedQueryOne, searchMemory, _resetEmbeddingCache, _resetEmbedCaches, reembedStaleVectors } = await import("./index");
45
+ const results: Array<{ operation: string; samples: number; median_ms: number; p95_ms: number }> = [];
46
+ async function time(operation: string, count: number, run: (index: number) => Promise<void>) {
47
+ const elapsed: number[] = [];
48
+ for (let i = 0; i < count; i++) {
49
+ const start = performance.now();
50
+ await run(i);
51
+ elapsed.push(performance.now() - start);
52
+ }
53
+ elapsed.sort((a, b) => a - b);
54
+ results.push({
55
+ operation, samples: count,
56
+ median_ms: Number(elapsed[Math.floor(elapsed.length / 2)].toFixed(3)),
57
+ p95_ms: Number(elapsed[Math.ceil(elapsed.length * 0.95) - 1].toFixed(3)),
58
+ });
59
+ }
60
+ const query = "What is the deployment rollback procedure?";
61
+ const validate = (vector: number[] | null) => {
62
+ expect(vector).toHaveLength(384);
63
+ expect(vector?.every(Number.isFinite)).toBe(true);
64
+ };
65
+ await time("cold pipeline + first query inference", 1, async () => {
66
+ validate((await embedLocally([query], "query"))[0]);
67
+ });
68
+ await time("warm pipeline, uncached short query", 10, async (i) => {
69
+ validate((await embedLocally([`${query} revision ${i}`], "query"))[0]);
70
+ });
71
+ await time("warm pipeline, 16 short passages", 3, async () => {
72
+ const vectors = await embedLocally(Array.from({ length: 16 }, (_, i) => `Deployment step ${i}: preserve backups and verify health.`), "passage");
73
+ expect(vectors).toHaveLength(16);
74
+ vectors.forEach(validate);
75
+ });
76
+ await embedQueryOne(query);
77
+ await time("query embedding content-cache hit", 20, async () => { validate(await embedQueryOne(query)); });
78
+ const vector = await embedQueryOne(query);
79
+ const encoded = JSON.stringify(vector);
80
+ const db = state.db;
81
+ db.prepare("INSERT INTO threads(thread_id,agent_id,created_at,updated_at) VALUES ('bench','bench',?,?)")
82
+ .run("2026-01-01", "2026-01-01");
83
+ const memory = db.prepare("INSERT INTO memory_store(namespace,key,value,created_at,updated_at,embedding) VALUES ('benchmark',?,?,?,?,?)");
84
+ const message = db.prepare("INSERT INTO messages(msg_id,thread_id,role,content,created_at,embedding) VALUES (?,'bench','assistant',?,?,?)");
85
+ const job = db.prepare("INSERT INTO message_embedding_jobs(message_id,source_hash,state,created_at,updated_at,model_signature) VALUES (?,?,'done','2026-01-01','2026-01-01',?)");
86
+ for (const rows of [1_000, 10_000]) {
87
+ db.exec("DELETE FROM memory_store WHERE namespace='benchmark'; DELETE FROM message_embedding_jobs; DELETE FROM messages; BEGIN");
88
+ for (let i = 0; i < rows / 2; i++) {
89
+ const content = `Synthetic deployment rollback procedure ${i}: restore backup, verify health, then resume traffic.`;
90
+ memory.run(`key-${i}`, content, "2026-01-01", "2026-01-01", encoded);
91
+ message.run(`msg-${i}`, content, "2026-01-01", encoded);
92
+ job.run(`msg-${i}`, "synthetic", LOCAL_EMBEDDING_MODEL_ID);
93
+ }
94
+ db.exec("COMMIT");
95
+ _resetEmbedCaches();
96
+ await time(`${rows} vectors: first search, cold row cache`, 1, async () => {
97
+ const hits = await searchMemory(query, { literal: true, limit: 10 });
98
+ expect(hits).toHaveLength(10);
99
+ });
100
+ await reembedStaleVectors(384);
101
+ await time(`${rows} vectors: warm rows + cached query`, 10, async () => {
102
+ expect(await searchMemory(query, { literal: true, limit: 10 })).toHaveLength(10);
103
+ });
104
+ await time(`${rows} vectors: warm rows + new query inference`, 5, async (i) => {
105
+ _resetEmbeddingCache();
106
+ expect(await searchMemory(`${query} check ${i}`, { literal: true, limit: 10 })).toHaveLength(10);
107
+ });
108
+ await reembedStaleVectors(384);
109
+ }
110
+ console.table(results);
111
+ console.log("Synthetic repeated 384-dimensional vectors; measures scan/sort cost, not retrieval quality. No HTTP transport or provider network latency.");
112
+ },
113
+ 120_000,
114
+ );
@@ -0,0 +1,99 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-dashboard-cache-"));
8
+ process.env.JARELA_DB_DIR = tmpRoot;
9
+ const { getDb, closeDb, DB_PATH } = await import("@/lib/db");
10
+ const { getDashboardMetrics } = await import("./dashboard-metrics");
11
+ const { recordMessageUsage } = await import("./message-usage");
12
+ const { getPricingTables } = await import("./pricing");
13
+ const pricing = await import("./pricing");
14
+
15
+ beforeEach(() => {
16
+ const db = getDb();
17
+ db.exec("DELETE FROM message_usage; DELETE FROM messages; DELETE FROM threads;");
18
+ const now = new Date().toISOString();
19
+ db.prepare("INSERT INTO threads(thread_id, agent_id, created_at, updated_at) VALUES (?,?,?,?)")
20
+ .run("t1", "a1", now, now);
21
+ db.prepare("INSERT INTO messages(msg_id, thread_id, role, content, created_at) VALUES (?,?,?,?,?)")
22
+ .run("m1", "t1", "assistant", " legacy estimate ", now);
23
+ });
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ vi.restoreAllMocks();
27
+ });
28
+ afterAll(() => {
29
+ closeDb();
30
+ rmSync(tmpRoot, { recursive: true, force: true });
31
+ });
32
+
33
+ describe("dashboard metrics cache", () => {
34
+ it("reuses a result only while the data, pricing, and window are unchanged", async () => {
35
+ const first = await getDashboardMetrics(30);
36
+ expect(await getDashboardMetrics(30)).toBe(first);
37
+ expect(first.summary.output_tokens_est).toBe(4);
38
+ expect(await getDashboardMetrics(7)).not.toBe(first);
39
+ });
40
+
41
+ it("invalidates immediately after local writes", async () => {
42
+ const first = await getDashboardMetrics();
43
+ getDb().prepare("UPDATE messages SET content=? WHERE msg_id=?").run("a".repeat(100), "m1");
44
+ const next = await getDashboardMetrics();
45
+ expect(next).not.toBe(first);
46
+ expect(next.summary.output_tokens_est).toBe(25);
47
+ });
48
+
49
+ it("invalidates for writes through another connection", async () => {
50
+ const first = await getDashboardMetrics();
51
+ const writer = new DatabaseSync(DB_PATH);
52
+ try {
53
+ writer.prepare("UPDATE messages SET content=? WHERE msg_id=?").run("b".repeat(80), "m1");
54
+ const next = await getDashboardMetrics();
55
+ expect(next).not.toBe(first);
56
+ expect(next.summary.output_tokens_est).toBe(20);
57
+ } finally {
58
+ writer.close();
59
+ }
60
+ });
61
+
62
+ it("expires after five seconds and across UTC day boundaries", async () => {
63
+ vi.useFakeTimers();
64
+ vi.setSystemTime(new Date("2026-10-18T23:59:59.000Z"));
65
+ const first = await getDashboardMetrics();
66
+ vi.setSystemTime(new Date("2026-10-19T00:00:00.000Z"));
67
+ const nextDay = await getDashboardMetrics();
68
+ expect(nextDay).not.toBe(first);
69
+ vi.advanceTimersByTime(5000);
70
+ expect(await getDashboardMetrics()).not.toBe(nextDay);
71
+ });
72
+
73
+ it("invalidates when pricing tables change", async () => {
74
+ const first = await getDashboardMetrics();
75
+ const tables = getPricingTables();
76
+ vi.spyOn(pricing, "getPricingTables").mockReturnValue({ ...tables });
77
+ expect(await getDashboardMetrics()).not.toBe(first);
78
+ });
79
+
80
+ it("preserves snapshot tokens, costs, and tool counts without needing the body", async () => {
81
+ getDb().prepare("UPDATE messages SET content=?, tool_events=? WHERE msg_id='m1'")
82
+ .run("x".repeat(100_000), JSON.stringify([
83
+ { id: "call1", phase: "call", name: "test", payload: {} },
84
+ { id: "call1", phase: "result", name: "test", payload: { ok: true } },
85
+ ]));
86
+ recordMessageUsage({
87
+ message_id: "m1", thread_id: "t1", agent_id: "a1", agent_name: "Synthetic",
88
+ provider: "synthetic", model_id: "synthetic", model_config_name: null,
89
+ input_tokens: 100, output_tokens: 20, input_rate_usd_per_mtok: null,
90
+ output_rate_usd_per_mtok: null, cost_usd: 0.5,
91
+ });
92
+ const data = await getDashboardMetrics();
93
+ expect(data.summary).toMatchObject({
94
+ input_tokens_est: 100, output_tokens_est: 20,
95
+ recorded_cost_usd: 0.5, tool_calls: 1, tool_successes: 1,
96
+ });
97
+ expect(data.summary.data_quality.measured_messages).toBe(1);
98
+ });
99
+ });
@@ -12,6 +12,16 @@ import {
12
12
 
13
13
  const CHARS_PER_TOKEN = 4;
14
14
  const DEFAULT_WINDOW_DAYS = 30;
15
+ const METRICS_CACHE_MS = 5_000;
16
+
17
+ let metricsCache: {
18
+ db: ReturnType<typeof getDb>;
19
+ changes: number;
20
+ dataVersion: number;
21
+ tables: PricingTables;
22
+ createdAt: number;
23
+ metrics: DashboardMetrics;
24
+ } | null = null;
15
25
 
16
26
  export interface DashboardTierTokens {
17
27
  hot_tokens: number;
@@ -256,10 +266,25 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
256
266
  const now = new Date();
257
267
  const boundedDays = Number.isFinite(days) ? Math.min(120, Math.max(7, Math.floor(days))) : DEFAULT_WINDOW_DAYS;
258
268
  const since = new Date(now.getTime() - (boundedDays * 24 * 60 * 60 * 1000)).toISOString();
269
+ const db = getDb();
270
+ const tables = getPricingTables();
271
+ // total_changes covers this connection; data_version covers other writers.
272
+ const { changes } = db.prepare("SELECT total_changes() AS changes").get() as { changes: number };
273
+ const { data_version: dataVersion } = db.prepare("PRAGMA data_version").get() as { data_version: number };
274
+ if (metricsCache?.db === db && metricsCache.changes === changes
275
+ && metricsCache.dataVersion === dataVersion && metricsCache.tables === tables
276
+ && metricsCache.metrics.days === boundedDays
277
+ && metricsCache.metrics.generated_at.slice(0, 10) === now.toISOString().slice(0, 10)
278
+ && now.getTime() - metricsCache.createdAt >= 0
279
+ && now.getTime() - metricsCache.createdAt < METRICS_CACHE_MS) {
280
+ return metricsCache.metrics;
281
+ }
259
282
 
260
- const usageRows = getDb()
283
+ const usageRows = db
261
284
  .prepare(
262
- `SELECT m.created_at, m.role, m.content, m.tool_events, m.thread_id, t.agent_id,
285
+ `SELECT m.created_at, m.role,
286
+ CASE WHEN mu.input_tokens IS NULL THEN m.content ELSE '' END AS content,
287
+ m.tool_events, m.thread_id, t.agent_id,
263
288
  a.name AS agent_name, mc.provider AS provider,
264
289
  mc.model_id AS model_id,
265
290
  COALESCE(ta.model_config_name, a.model_config_name, dmc.name) AS model_config_name,
@@ -313,7 +338,6 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
313
338
  }
314
339
  }
315
340
 
316
- const tables = getPricingTables();
317
341
  const { byProvider, generatedAt } = tables;
318
342
  const dayMap = seedDayBuckets(now, boundedDays);
319
343
  const agentMap = new Map<string, AgentBucket>();
@@ -645,7 +669,7 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
645
669
  };
646
670
  }
647
671
 
648
- return {
672
+ const metrics: DashboardMetrics = {
649
673
  generated_at: now.toISOString(),
650
674
  days: boundedDays,
651
675
  summary: {
@@ -701,6 +725,8 @@ export async function getDashboardMetrics(days = DEFAULT_WINDOW_DAYS): Promise<D
701
725
  notes: "Dashboard cost is recalculated from captured provider token usage and the latest matching rates. The original per-turn cost snapshots remain unchanged; legacy rows use content-length token estimates.",
702
726
  },
703
727
  };
728
+ metricsCache = { db, changes, dataVersion, tables, createdAt: now.getTime(), metrics };
729
+ return metrics;
704
730
  }
705
731
 
706
732
  function seedDayBuckets(now: Date, days: number): Map<string, DayBucket> {