@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
@@ -87,6 +87,33 @@ describe("message embedding jobs", () => {
87
87
  expect(nextMessageEmbeddingDelayMs(currentTime)).toBeGreaterThan(0);
88
88
  });
89
89
 
90
+ it("finds the earliest pending deadline or processing lease without including terminal jobs", () => {
91
+ const nowMs = Date.now();
92
+ expect(nextMessageEmbeddingDelayMs(nowMs)).toBeNull();
93
+ for (const id of ["pending", "processing", "done", "no-lease"]) {
94
+ addMessage(id, "synthetic embedding input");
95
+ enqueueMessageEmbeddingJob(id, "synthetic embedding input");
96
+ }
97
+ const db = getDb();
98
+ db.prepare("UPDATE message_embedding_jobs SET available_at_ms=? WHERE message_id='pending'").run(nowMs + 3000);
99
+ db.prepare("UPDATE message_embedding_jobs SET state='processing',lease_until_ms=? WHERE message_id='processing'")
100
+ .run(nowMs + 1000);
101
+ db.exec("UPDATE message_embedding_jobs SET state='done',available_at_ms=0 WHERE message_id='done'");
102
+ db.exec("UPDATE message_embedding_jobs SET state='processing',lease_until_ms=NULL WHERE message_id='no-lease'");
103
+ expect(nextMessageEmbeddingDelayMs(nowMs)).toBe(1000);
104
+ db.exec("DELETE FROM message_embedding_jobs WHERE message_id='processing'");
105
+ expect(nextMessageEmbeddingDelayMs(nowMs)).toBe(3000);
106
+ db.exec("DELETE FROM message_embedding_jobs WHERE message_id='pending'");
107
+ expect(nextMessageEmbeddingDelayMs(nowMs)).toBeNull();
108
+ });
109
+
110
+ it("returns zero for an already-expired processing lease", () => {
111
+ addMessage("expired", "synthetic expired embedding input");
112
+ enqueueMessageEmbeddingJob("expired", "synthetic expired embedding input");
113
+ getDb().exec("UPDATE message_embedding_jobs SET state='processing',lease_until_ms=1 WHERE message_id='expired'");
114
+ expect(nextMessageEmbeddingDelayMs(100)).toBe(0);
115
+ });
116
+
90
117
  it("cascades pending work when its source message is deleted", () => {
91
118
  addMessage("msg-4", "message pending embedding");
92
119
  enqueueMessageEmbeddingJob("msg-4", "message pending embedding");
@@ -102,9 +102,11 @@ export function claimMessageEmbeddingJobs(
102
102
 
103
103
  export function nextMessageEmbeddingDelayMs(nowMs = Date.now()): number | null {
104
104
  const row = getDb().prepare(
105
- `SELECT MIN(CASE WHEN state='pending' THEN available_at_ms ELSE lease_until_ms END) AS next_at_ms
106
- FROM message_embedding_jobs
107
- WHERE state IN ('pending','processing')`,
105
+ `SELECT MIN(next_at_ms) AS next_at_ms FROM (
106
+ SELECT MIN(available_at_ms) AS next_at_ms FROM message_embedding_jobs WHERE state='pending'
107
+ UNION ALL
108
+ SELECT MIN(lease_until_ms) AS next_at_ms FROM message_embedding_jobs WHERE state='processing'
109
+ )`,
108
110
  ).get() as { next_at_ms?: number | null } | undefined;
109
111
  return typeof row?.next_at_ms === "number" ? Math.max(0, row.next_at_ms - nowMs) : null;
110
112
  }
@@ -6,7 +6,8 @@ import { tmpdir } from "node:os";
6
6
  const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-message-usage-"));
7
7
  process.env.JARELA_DB_DIR = tmpRoot;
8
8
 
9
- const { recordMessageUsage, getMessageUsageByIds } = await import("./message-usage");
9
+ const { recordMessageUsage, getMessageUsageByIds, getLatestMessageUsageForThread } = await import("./message-usage");
10
+ const { getDb } = await import("@/lib/db");
10
11
  const getMessageUsage = (id: string) => getMessageUsageByIds([id]).get(id) ?? null;
11
12
 
12
13
  afterAll(() => {
@@ -14,6 +15,25 @@ afterAll(() => {
14
15
  });
15
16
 
16
17
  describe("message_usage snapshot store (ADR-0041)", () => {
18
+ it("returns the latest usage for only the requested thread", () => {
19
+ const db = getDb();
20
+ for (const [id, thread, timestamp] of [
21
+ ["ordered-old", "ordered-thread", "2026-10-01T00:00:00.000Z"],
22
+ ["ordered-other", "other-thread", "2026-10-18T00:00:00.000Z"],
23
+ ["ordered-new", "ordered-thread", "2026-10-10T00:00:00.000Z"],
24
+ ]) {
25
+ recordMessageUsage({
26
+ message_id: id, thread_id: thread, agent_id: "synthetic", agent_name: "Synthetic",
27
+ provider: "synthetic", model_id: "synthetic", model_config_name: null,
28
+ input_tokens: 1, output_tokens: 1, input_rate_usd_per_mtok: null,
29
+ output_rate_usd_per_mtok: null, cost_usd: 0,
30
+ });
31
+ db.prepare("UPDATE message_usage SET created_at=? WHERE message_id=?").run(timestamp, id);
32
+ }
33
+ expect(getLatestMessageUsageForThread("ordered-thread")?.message_id).toBe("ordered-new");
34
+ expect(getLatestMessageUsageForThread("missing-thread")).toBeNull();
35
+ });
36
+
17
37
  it("records and reads back a usage snapshot", () => {
18
38
  recordMessageUsage({
19
39
  message_id: "m1",
@@ -0,0 +1,31 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({ statSync: vi.fn(), readFileSync: vi.fn() }));
4
+ vi.mock("node:fs", () => mocks);
5
+ vi.mock("./known-rates", () => ({ knownRateModelIds: () => [], knownRateFor: () => null }));
6
+ const { getPricingTables } = await import("./pricing");
7
+
8
+ describe("pricing snapshot read cache", () => {
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ });
12
+
13
+ it("does not read or parse the snapshot again when its mtime is unchanged", () => {
14
+ mocks.statSync.mockReturnValue({ mtimeMs: 100 });
15
+ mocks.readFileSync.mockReturnValue(JSON.stringify({ generated_at: "first", sources: [] }));
16
+ const first = getPricingTables();
17
+ expect(getPricingTables()).toBe(first);
18
+ expect(mocks.statSync).toHaveBeenCalledTimes(2);
19
+ expect(mocks.readFileSync).toHaveBeenCalledTimes(1);
20
+ });
21
+
22
+ it("reloads rates when the snapshot changes", () => {
23
+ mocks.statSync.mockReturnValue({ mtimeMs: 200 });
24
+ mocks.readFileSync.mockReturnValue(JSON.stringify({ generated_at: "second", sources: [] }));
25
+ expect(getPricingTables().generatedAt).toBe("second");
26
+ mocks.statSync.mockReturnValue({ mtimeMs: 300 });
27
+ mocks.readFileSync.mockReturnValue(JSON.stringify({ generated_at: "third", sources: [] }));
28
+ expect(getPricingTables().generatedAt).toBe("third");
29
+ expect(mocks.readFileSync).toHaveBeenCalledTimes(2);
30
+ });
31
+ });
@@ -112,6 +112,9 @@ function readSnapshotSync(): { path: string; snapshot: PricingSnapshot | null; m
112
112
  try {
113
113
  const p = snapshotPath();
114
114
  const stat = statSync(p);
115
+ if (cached?.path === p && cached.mtimeMs === stat.mtimeMs) {
116
+ return { path: p, snapshot: null, mtimeMs: stat.mtimeMs };
117
+ }
115
118
  const raw = readFileSync(p, "utf8");
116
119
  return { path: p, snapshot: JSON.parse(raw) as PricingSnapshot, mtimeMs: stat.mtimeMs };
117
120
  } catch {
@@ -14,6 +14,7 @@ const {
14
14
  registerDefaultPackages,
15
15
  _resetDefaultPackages,
16
16
  } = await import("./default-packages");
17
+ const { registeredTools, registeredCategory, registeredCapability } = await import("../runtime/registry");
17
18
 
18
19
  const { setPackageDisabled, listDisabledPackages } = await import(
19
20
  "@/lib/stores/disabled-packages"
@@ -52,6 +53,26 @@ describe("default LangChain packages", () => {
52
53
  expect(listDefaultPackages().every((p) => p.installed === false)).toBe(true);
53
54
  });
54
55
 
56
+ it("registers workspace packages with their declared categories and capabilities", () => {
57
+ const previousPackagesDir = process.env.JARELA_PACKAGES_DIR;
58
+ process.env.JARELA_PACKAGES_DIR = "";
59
+ try {
60
+ registerDefaultPackages();
61
+
62
+ expect(listDefaultPackages().every((p) => p.installed)).toBe(true);
63
+ const tools = registeredTools();
64
+ expect(tools.length).toBeGreaterThan(0);
65
+ for (const tool of tools) {
66
+ expect(registeredCategory(tool.name)).toBeDefined();
67
+ expect(["read", "write", "execute"]).toContain(registeredCapability(tool.name));
68
+ }
69
+ } finally {
70
+ _resetDefaultPackages();
71
+ if (previousPackagesDir === undefined) delete process.env.JARELA_PACKAGES_DIR;
72
+ else process.env.JARELA_PACKAGES_DIR = previousPackagesDir;
73
+ }
74
+ });
75
+
55
76
  it("findDefaultPackage returns the descriptor for a known id", () => {
56
77
  const d = findDefaultPackage("github");
57
78
  expect(d).not.toBeNull();
@@ -0,0 +1,72 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { isOptionalPackageInstalled, loadOptionalPackage } from "./optional-package";
6
+
7
+ describe("optional package resolution", () => {
8
+ let managedDir: string;
9
+ let originalPackagesDir: string | undefined;
10
+
11
+ beforeEach(() => {
12
+ originalPackagesDir = process.env.JARELA_PACKAGES_DIR;
13
+ managedDir = mkdtempSync(join(tmpdir(), "jarela-optional-packages-"));
14
+ process.env.JARELA_PACKAGES_DIR = managedDir;
15
+ });
16
+
17
+ afterEach(() => {
18
+ if (originalPackagesDir === undefined) delete process.env.JARELA_PACKAGES_DIR;
19
+ else process.env.JARELA_PACKAGES_DIR = originalPackagesDir;
20
+ rmSync(managedDir, { recursive: true, force: true });
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ it("prefers a package installed in the managed packages directory", () => {
25
+ const packageDir = join(managedDir, "node_modules", "fake-optional-package");
26
+ mkdirSync(packageDir, { recursive: true });
27
+ writeFileSync(join(packageDir, "package.json"), JSON.stringify({
28
+ name: "fake-optional-package",
29
+ version: "1.0.0",
30
+ main: "index.cjs",
31
+ }));
32
+ writeFileSync(join(packageDir, "index.cjs"), 'module.exports = { source: "managed" };');
33
+
34
+ expect(loadOptionalPackage("fake-optional-package")).toEqual({ source: "managed" });
35
+ });
36
+
37
+ it.each([
38
+ "@circuitwall/atlassian-langchain",
39
+ "@circuitwall/github-langchain",
40
+ "@circuitwall/icloud-langchain",
41
+ "@circuitwall/jira-align-langchain",
42
+ "@circuitwall/ms-todo-langchain",
43
+ ])("falls back to the app workspace for %s", (packageName) => {
44
+ delete process.env.JARELA_PACKAGES_DIR;
45
+ expect(loadOptionalPackage(packageName)).not.toBeNull();
46
+ expect(isOptionalPackageInstalled(packageName)).toBe(true);
47
+ });
48
+
49
+ it("exposes exports from the workspace fallback", () => {
50
+ delete process.env.JARELA_PACKAGES_DIR;
51
+ const loaded = loadOptionalPackage<{ githubFetch?: unknown }>("@circuitwall/github-langchain");
52
+
53
+ expect(loaded).not.toBeNull();
54
+ expect(typeof loaded?.githubFetch).toBe("function");
55
+ });
56
+
57
+ it("treats an absent optional package as a normal miss", () => {
58
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
59
+
60
+ expect(loadOptionalPackage("@circuitwall/not-installed")).toBeNull();
61
+ expect(error).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("does not treat unrelated app dependencies as installed optional packages", () => {
65
+ expect(loadOptionalPackage("@langchain/core")).toBeNull();
66
+ });
67
+
68
+ it("honors an explicit managed directory even when a workspace package exists", () => {
69
+ expect(loadOptionalPackage("@circuitwall/github-langchain")).toBeNull();
70
+ expect(isOptionalPackageInstalled("@circuitwall/github-langchain")).toBe(false);
71
+ });
72
+ });
@@ -22,12 +22,40 @@ function managedPackagePath(packageName: string): string {
22
22
  return join(getPackagesDir(), "node_modules", packageName);
23
23
  }
24
24
 
25
+ interface ResolvedOptionalPackage {
26
+ req: OptionalRequire;
27
+ path: string;
28
+ }
29
+
30
+ function resolveOptionalPackage(packageName: string): ResolvedOptionalPackage | null {
31
+ const managedReq = createRuntimeRequire(join(getPackagesDir(), "_anchor"));
32
+ try {
33
+ return {
34
+ req: managedReq,
35
+ path: managedReq.resolve(managedPackagePath(packageName)),
36
+ };
37
+ } catch {
38
+ // Workspace builds expose bundled @circuitwall packages from the app root.
39
+ }
40
+
41
+ if (process.env.JARELA_PACKAGES_DIR?.trim()) return null;
42
+ if (!packageName.startsWith("@circuitwall/")) return null;
43
+
44
+ const appReq = createRuntimeRequire(join(process.cwd(), "package.json"));
45
+ try {
46
+ return { req: appReq, path: appReq.resolve(packageName) };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
25
52
  export function loadOptionalPackage<T extends AnyModule>(packageName: string): T | null {
26
53
  const override = testOverrides.get(packageName);
27
54
  if (override) return override as T;
55
+ const resolved = resolveOptionalPackage(packageName);
56
+ if (!resolved) return null;
28
57
  try {
29
- const req = createRuntimeRequire(join(getPackagesDir(), "_anchor"));
30
- return req(managedPackagePath(packageName)) as T;
58
+ return resolved.req(resolved.path) as T;
31
59
  } catch (err) {
32
60
  // Keep the optional boundary non-fatal, but leave the actual load failure
33
61
  // visible so missing peer dependencies are diagnosable from server logs.
@@ -68,9 +96,10 @@ export function optionalFunction<T extends (...args: any[]) => any>(
68
96
  }
69
97
 
70
98
  export function isOptionalPackageInstalled(packageName: string): boolean {
99
+ const resolved = resolveOptionalPackage(packageName);
100
+ if (!resolved) return false;
71
101
  try {
72
- const req = createRuntimeRequire(join(getPackagesDir(), "_anchor"));
73
- req(managedPackagePath(packageName));
102
+ resolved.req(resolved.path);
74
103
  return true;
75
104
  } catch (err) {
76
105
  console.error(`[optional-package] failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseHref } from "./navigate";
3
+
4
+ describe("parseHref legacy Knowledge routes", () => {
5
+ it.each([
6
+ "/?tab=documents",
7
+ "/?tab=memory",
8
+ "/?tab=tools&item=documents",
9
+ "/?tab=tools&item=memory",
10
+ ])("routes %s to Knowledge", (href) => {
11
+ expect(parseHref(href)).toMatchObject({ tab: "memory", item: undefined, external: false });
12
+ });
13
+ });
@@ -40,8 +40,16 @@ export function parseHref(input: string | null | undefined): ParsedHref {
40
40
 
41
41
  const params = new URLSearchParams(queryStr);
42
42
  const tabRaw = params.get("tab");
43
- const tab = TABS.includes(tabRaw as Tab) ? (tabRaw as Tab) : undefined;
44
- const item = params.get("item") ?? undefined;
43
+ let tab = TABS.includes(tabRaw as Tab) ? (tabRaw as Tab) : undefined;
44
+ let item = params.get("item") ?? undefined;
45
+ if (
46
+ tab === "documents" ||
47
+ tab === "memory" ||
48
+ (tab === "tools" && (item === "memory" || item === "documents"))
49
+ ) {
50
+ tab = "memory";
51
+ item = undefined;
52
+ }
45
53
  const thread = params.get("thread") ?? undefined;
46
54
  const agent = params.get("agent") ?? undefined;
47
55
  return { tab, item, thread, agent, hash, external: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Jarela — local chat interface for LangGraph agents (multi-provider, single-process, SQLite-backed).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Andrew Ge Wu",