@polderlabs/bizar 4.7.2 → 4.9.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 (44) hide show
  1. package/bizar-dash/dist/assets/index-DU61awG3.js +9 -0
  2. package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
  4. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
  5. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
  6. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
  8. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
  9. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  13. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  14. package/bizar-dash/src/server/memory-store.mjs +121 -0
  15. package/bizar-dash/src/server/otel.mjs +133 -0
  16. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  17. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  18. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  19. package/bizar-dash/src/server/server.mjs +40 -0
  20. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  21. package/bizar-dash/src/web/lib/search.ts +115 -0
  22. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  23. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  24. package/bizar-dash/src/web/styles/memory.css +84 -1
  25. package/bizar-dash/src/web/styles/settings.css +80 -0
  26. package/bizar-dash/src/web/views/Memory.tsx +6 -1
  27. package/bizar-dash/src/web/views/Settings.tsx +96 -0
  28. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  29. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  30. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  31. package/bizar-dash/tests/backup-restore.test.tsx +35 -17
  32. package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
  33. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  34. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  35. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  36. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  37. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  38. package/bizar-dash/tests/otel.test.mjs +188 -0
  39. package/cli/commands/dash.mjs +6 -0
  40. package/package.json +7 -1
  41. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  42. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  43. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  44. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
@@ -0,0 +1,96 @@
1
+ /**
2
+ * bizar-dash/tests/docker-build.test.mjs
3
+ *
4
+ * v4.9.0 — Validate Docker deployment configuration files.
5
+ *
6
+ * Checks:
7
+ * - Dockerfile exists and has valid syntax (FROM, CMD/ENTRYPOINT, EXPOSE, HEALTHCHECK)
8
+ * - docker-compose.yml exists with bizar-dash service and ports
9
+ * - .dockerignore exists
10
+ */
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const ROOT = join(__dirname, '..', '..');
20
+
21
+ const DOCKERFILE = join(ROOT, 'Dockerfile');
22
+ const COMPOSE_FILE = join(ROOT, 'docker-compose.yml');
23
+ const DOCKERIGNORE = join(ROOT, '.dockerignore');
24
+
25
+ // ── Dockerfile ──────────────────────────────────────────────────────────────
26
+
27
+ test('Dockerfile exists at root', () => {
28
+ assert.ok(existsSync(DOCKERFILE), 'Dockerfile should exist at project root');
29
+ });
30
+
31
+ test('Dockerfile has FROM instruction', () => {
32
+ const content = readFileSync(DOCKERFILE, 'utf8');
33
+ assert.match(content, /^FROM\s+\S+/m, 'Dockerfile must contain a FROM instruction');
34
+ });
35
+
36
+ test('Dockerfile has CMD or ENTRYPOINT', () => {
37
+ const content = readFileSync(DOCKERFILE, 'utf8');
38
+ assert.ok(
39
+ /^(CMD|ENTRYPOINT)\s+/m.test(content),
40
+ 'Dockerfile must have a CMD or ENTRYPOINT instruction',
41
+ );
42
+ });
43
+
44
+ test('Dockerfile exposes at least one port', () => {
45
+ const content = readFileSync(DOCKERFILE, 'utf8');
46
+ assert.match(content, /^EXPOSE\s+\d+/m, 'Dockerfile must expose at least one port');
47
+ });
48
+
49
+ test('Dockerfile has HEALTHCHECK', () => {
50
+ const content = readFileSync(DOCKERFILE, 'utf8');
51
+ assert.match(content, /^HEALTHCHECK\s+/m, 'Dockerfile must have a HEALTHCHECK instruction');
52
+ });
53
+
54
+ test('Dockerfile uses multi-stage build', () => {
55
+ const content = readFileSync(DOCKERFILE, 'utf8');
56
+ const fromCount = content.match(/^FROM\s+\S+/gm);
57
+ assert.ok(fromCount && fromCount.length >= 2, 'Dockerfile must have at least 2 FROM instructions (multi-stage)');
58
+ });
59
+
60
+ // ── docker-compose.yml ──────────────────────────────────────────────────────
61
+
62
+ test('docker-compose.yml exists at root', () => {
63
+ assert.ok(existsSync(COMPOSE_FILE), 'docker-compose.yml should exist at project root');
64
+ });
65
+
66
+ test('docker-compose.yml has bizar-dash service with build and ports', () => {
67
+ const content = readFileSync(COMPOSE_FILE, 'utf8');
68
+ assert.match(content, /bizar-dash:/, 'docker-compose must define a bizar-dash service');
69
+ assert.match(content, /build:\s*\./, 'bizar-dash service must have build: .');
70
+ assert.match(content, /ports:/, 'bizar-dash service must expose ports');
71
+ });
72
+
73
+ test('docker-compose.yml defines required volumes', () => {
74
+ const content = readFileSync(COMPOSE_FILE, 'utf8');
75
+ assert.match(content, /bizar-config:/, 'Must define bizar-config volume');
76
+ assert.match(content, /bizar-memory:/, 'Must define bizar-memory volume');
77
+ assert.match(content, /bizar-usage:/, 'Must define bizar-usage volume');
78
+ assert.match(content, /bizar-backups:/, 'Must define bizar-backups volume');
79
+ });
80
+
81
+ test('docker-compose.yml has healthcheck', () => {
82
+ const content = readFileSync(COMPOSE_FILE, 'utf8');
83
+ assert.match(content, /healthcheck:/, 'bizar-dash service must have a healthcheck');
84
+ });
85
+
86
+ // ── .dockerignore ───────────────────────────────────────────────────────────
87
+
88
+ test('.dockerignore exists at root', () => {
89
+ assert.ok(existsSync(DOCKERIGNORE), '.dockerignore should exist at project root');
90
+ });
91
+
92
+ test('.dockerignore excludes node_modules and .git', () => {
93
+ const content = readFileSync(DOCKERIGNORE, 'utf8');
94
+ assert.match(content, /node_modules/, '.dockerignore must exclude node_modules');
95
+ assert.match(content, /\.git/, '.dockerignore must exclude .git');
96
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * tests/lib/search-fuzzy.test.ts
3
+ *
4
+ * v4.9 — Unit tests for fuzzy search with typo tolerance.
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import {
8
+ levenshtein,
9
+ scoreField,
10
+ fuzzySearch,
11
+ type SearchableItem,
12
+ } from '../../src/web/lib/search';
13
+
14
+ /* ─── Levenshtein ─── */
15
+
16
+ describe('levenshtein', () => {
17
+ it('returns 0 for identical strings', () => {
18
+ expect(levenshtein('hello', 'hello')).toBe(0);
19
+ });
20
+
21
+ it('returns length for completely different strings', () => {
22
+ expect(levenshtein('abc', 'xyz')).toBe(3);
23
+ });
24
+
25
+ it('counts single-character insertion', () => {
26
+ expect(levenshtein('cat', 'cats')).toBe(1);
27
+ });
28
+
29
+ it('counts single-character deletion', () => {
30
+ expect(levenshtein('cats', 'cat')).toBe(1);
31
+ });
32
+
33
+ it('counts single-character substitution', () => {
34
+ expect(levenshtein('cat', 'car')).toBe(1);
35
+ });
36
+
37
+ it('handles empty strings', () => {
38
+ expect(levenshtein('', 'abc')).toBe(3);
39
+ expect(levenshtein('abc', '')).toBe(3);
40
+ expect(levenshtein('', '')).toBe(0);
41
+ });
42
+
43
+ it('handles transposition as two edits', () => {
44
+ expect(levenshtein('ab', 'ba')).toBe(2);
45
+ });
46
+ });
47
+
48
+ /* ─── scoreField ─── */
49
+
50
+ describe('scoreField', () => {
51
+ const field = 'accent color';
52
+
53
+ it('exact match scores 100', () => {
54
+ expect(scoreField('accent color', field)).toBe(100);
55
+ });
56
+
57
+ it('prefix match scores 90', () => {
58
+ expect(scoreField('acc', field)).toBe(90);
59
+ expect(scoreField('accent', field)).toBe(90); // starts with "accent"
60
+ });
61
+
62
+ it('word boundary match scores 80', () => {
63
+ // "color" is a separate word in "accent color"
64
+ expect(scoreField('color', field)).toBe(80);
65
+ // "colo" starts at "color" word boundary, not field start
66
+ expect(scoreField('colo', field)).toBe(80);
67
+ });
68
+
69
+ it('typo within 2 chars scores 40', () => {
70
+ // "accent" vs "axcent" (one char off)
71
+ expect(scoreField('axcent', 'accent color')).toBe(40);
72
+ // "accent" vs "accant" (one char off)
73
+ const score = scoreField('accant', 'accent color');
74
+ expect(score).toBe(40);
75
+ });
76
+
77
+ it('substring non-prefix, non-word-boundary match scores 30', () => {
78
+ // "cent" is not at a word boundary in "accent color"
79
+ expect(scoreField('cent', 'accent color')).toBe(30);
80
+ });
81
+
82
+ it('returns 0 for no match', () => {
83
+ expect(scoreField('zzzzz', 'accent color')).toBe(0);
84
+ });
85
+
86
+ it('handles case insensitivity via pre-lowered inputs', () => {
87
+ // Our callers lower case before passing — verify the scoring works
88
+ // "accent" is a prefix of "accent color" → scores 90
89
+ expect(scoreField('Accent'.toLowerCase(), 'Accent Color'.toLowerCase())).toBe(90);
90
+ });
91
+ });
92
+
93
+ /* ─── fuzzySearch ─── */
94
+
95
+ describe('fuzzySearch', () => {
96
+ const items: SearchableItem[] = [
97
+ { key: 'theme.accent', label: 'Accent color', section: 'theme', value: '#8b5cf6' },
98
+ { key: 'theme.fontFamily', label: 'Font family', section: 'theme' },
99
+ { key: 'ui.layout', label: 'UI layout', section: 'layout', value: 'topnav' },
100
+ { key: 'defaultAgent', label: 'Default agent', section: 'general', value: 'odin' },
101
+ { key: 'notifications.onAgentComplete', label: 'Notify on agent complete', section: 'notifications', description: 'Show toast when an agent finishes' },
102
+ ];
103
+
104
+ it('returns empty array for empty query', () => {
105
+ expect(fuzzySearch('', items)).toEqual([]);
106
+ expect(fuzzySearch(' ', items)).toEqual([]);
107
+ });
108
+
109
+ it('finds exact match by label', () => {
110
+ const results = fuzzySearch('accent color', items);
111
+ expect(results.length).toBeGreaterThanOrEqual(1);
112
+ expect(results[0].key).toBe('theme.accent');
113
+ expect(results[0].score).toBe(100);
114
+ });
115
+
116
+ it('finds match by value', () => {
117
+ const results = fuzzySearch('#8b5cf6', items);
118
+ expect(results.some((r) => r.key === 'theme.accent')).toBe(true);
119
+ });
120
+
121
+ it('finds match by description', () => {
122
+ const results = fuzzySearch('toast', items);
123
+ expect(results.some((r) => r.key === 'notifications.onAgentComplete')).toBe(true);
124
+ });
125
+
126
+ it('finds match via typo tolerance', () => {
127
+ const results = fuzzySearch('axcent', items);
128
+ expect(results.some((r) => r.key === 'theme.accent')).toBe(true);
129
+ });
130
+
131
+ it('sorts by score descending', () => {
132
+ const results = fuzzySearch('agent', items);
133
+ for (let i = 1; i < results.length; i++) {
134
+ expect(results[i].score).toBeLessThanOrEqual(results[i - 1].score);
135
+ }
136
+ });
137
+
138
+ it('returns empty when nothing matches', () => {
139
+ const results = fuzzySearch('zzzznonexistent', items);
140
+ expect(results).toEqual([]);
141
+ });
142
+
143
+ it('sets matchedField and matchedTerm on results', () => {
144
+ const results = fuzzySearch('accent', items);
145
+ expect(results.length).toBeGreaterThan(0);
146
+ expect(results[0].matchedField).toBe('label');
147
+ expect(results[0].matchedTerm).toBe('Accent color');
148
+ });
149
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * tests/memory-graph-view.test.tsx
3
+ *
4
+ * Tests for MemoryGraphView (SVG force-directed graph).
5
+ */
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ import { render, screen, fireEvent } from '@testing-library/react';
8
+ import React from 'react';
9
+ import { MemoryGraphView, type GraphData } from '../src/web/views/memory/MemoryGraphView';
10
+
11
+ const EMPTY_GRAPH: GraphData = { nodes: [], edges: [] };
12
+
13
+ const THREE_NODE_GRAPH: GraphData = {
14
+ nodes: [
15
+ { id: 'notes/alpha.md', label: 'Alpha Note', type: 'note', size: 1, group: 'notes' },
16
+ { id: 'notes/beta.md', label: 'Beta Note', type: 'note', size: 2, group: 'notes' },
17
+ { id: 'decisions/dec.md', label: 'Decision', type: 'note', size: 1, group: 'decisions' },
18
+ ],
19
+ edges: [
20
+ { source: 'notes/alpha.md', target: 'notes/beta.md', type: 'links_to', weight: 1 },
21
+ { source: 'notes/beta.md', target: 'decisions/dec.md', type: 'links_to', weight: 1 },
22
+ ],
23
+ };
24
+
25
+ describe('MemoryGraphView', () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ it('renders SVG with empty graph', () => {
31
+ render(<MemoryGraphView data={EMPTY_GRAPH} />);
32
+ const svg = document.querySelector('.memory-graph-canvas');
33
+ expect(svg).toBeInTheDocument();
34
+ });
35
+
36
+ it('renders nodes and edges', () => {
37
+ render(<MemoryGraphView data={THREE_NODE_GRAPH} />);
38
+ // SVG should have circles for nodes (one per node).
39
+ const circles = document.querySelectorAll('.memory-graph-node');
40
+ expect(circles.length).toBe(3);
41
+ });
42
+
43
+ it('calls onNodeClick when a node is clicked', () => {
44
+ const onNodeClick = vi.fn();
45
+ render(<MemoryGraphView data={THREE_NODE_GRAPH} onNodeClick={onNodeClick} />);
46
+ const circles = document.querySelectorAll('.memory-graph-node');
47
+ // Click the first node circle group.
48
+ if (circles.length > 0) {
49
+ fireEvent.click(circles[0]);
50
+ }
51
+ expect(onNodeClick).toHaveBeenCalledTimes(1);
52
+ });
53
+
54
+ it('renders filtered nodes when filter is applied via data prop', () => {
55
+ const filteredData = {
56
+ nodes: THREE_NODE_GRAPH.nodes.filter((n) => n.label.includes('Alpha')),
57
+ edges: [],
58
+ };
59
+ render(<MemoryGraphView data={filteredData} />);
60
+ const circles = document.querySelectorAll('.memory-graph-node');
61
+ expect(circles.length).toBe(1);
62
+ });
63
+
64
+ it('node has correct type and group fields', () => {
65
+ render(<MemoryGraphView data={THREE_NODE_GRAPH} />);
66
+ const circles = document.querySelectorAll('.memory-graph-node');
67
+ expect(circles.length).toBeGreaterThan(0);
68
+ });
69
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * tests/memory-graph.test.mjs
3
+ *
4
+ * Tests for getObsidianLinkGraph in memory-store.mjs
5
+ * and getLightRAGGraph in memory-lightrag.mjs.
6
+ */
7
+
8
+ import { describe, it, beforeEach, afterEach } from 'node:test';
9
+ import assert from 'node:assert';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
13
+
14
+ const TEST_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
15
+
16
+ describe('getObsidianLinkGraph', () => {
17
+ let projectRoot;
18
+
19
+ beforeEach(() => {
20
+ projectRoot = join(tmpdir(), `bizar-graph-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
21
+ mkdirSync(projectRoot, { recursive: true });
22
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
23
+ });
24
+
25
+ afterEach(() => {
26
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
27
+ });
28
+
29
+ function writeNote(relPath, body) {
30
+ const full = join(projectRoot, '.obsidian', relPath);
31
+ mkdirSync(join(projectRoot, '.obsidian', relPath.split('/').slice(0, -1).join('/') || '.'), { recursive: true });
32
+ const title = relPath.split('/').pop()?.replace(/\.md$/i, '') || relPath;
33
+ writeFileSync(full, `---\ntitle: ${title}\n---\n\n${body}`, 'utf8');
34
+ }
35
+
36
+ it('extracts wikilinks and builds nodes + edges', () => {
37
+ writeNote('notes/alpha.md', 'This links to [[Beta Note]] and [[Gamma]].');
38
+ writeNote('notes/beta.md', 'Back to [[Alpha]].');
39
+ writeNote('notes/gamma.md', 'No links here.');
40
+
41
+ const { nodes, edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
42
+
43
+ // Should have 3 nodes.
44
+ assert.ok(nodes.length >= 3, `Expected >= 3 nodes, got ${nodes.length}`);
45
+
46
+ // Each node should have required fields.
47
+ for (const n of nodes) {
48
+ assert.ok('id' in n, 'node must have id');
49
+ assert.ok('label' in n, 'node must have label');
50
+ assert.ok('type' in n, 'node must have type');
51
+ assert.ok('size' in n, 'node must have size');
52
+ assert.ok('group' in n, 'node must have group');
53
+ }
54
+
55
+ // Should have edges.
56
+ assert.ok(edges.length > 0, 'Expected at least 1 edge');
57
+ for (const e of edges) {
58
+ assert.ok('source' in e, 'edge must have source');
59
+ assert.ok('target' in e, 'edge must have target');
60
+ assert.ok('type' in e, 'edge must have type');
61
+ assert.ok('weight' in e, 'edge must have weight');
62
+ }
63
+ });
64
+
65
+ it('handles empty vault gracefully', () => {
66
+ const { nodes, edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
67
+ assert.ok(Array.isArray(nodes), 'nodes must be an array');
68
+ assert.ok(Array.isArray(edges), 'edges must be an array');
69
+ });
70
+
71
+ it('resolves wikilink targets by basename', () => {
72
+ writeNote('foo.md', 'See [[Bar]] for details.');
73
+ writeNote('bar.md', 'Related to [[Foo]].');
74
+ const { edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
75
+ // Should have resolved [[Bar]] -> bar.md and [[Foo]] -> foo.md.
76
+ assert.ok(edges.length >= 1, 'Expected at least 1 edge from wikilink resolution');
77
+ });
78
+
79
+ it('respects the limit parameter', () => {
80
+ for (let i = 0; i < 10; i++) {
81
+ writeNote(`note${i}.md`, i === 0 ? 'Links to [[Note1]].' : '');
82
+ }
83
+ const { nodes, edges } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 3 });
84
+ assert.ok(nodes.length <= 3, `Expected <= 3 nodes, got ${nodes.length}`);
85
+ assert.ok(edges.length <= 9, `Expected <= 9 edges, got ${edges.length}`);
86
+ });
87
+
88
+ it('node label uses title frontmatter when available', () => {
89
+ writeNote('mydoc.md', 'Content.');
90
+ const { nodes } = TEST_STORE.getObsidianLinkGraph({ projectRoot, limit: 200 });
91
+ const mydoc = nodes.find((n) => n.id === 'mydoc.md');
92
+ assert.ok(mydoc, 'mydoc.md should be a node');
93
+ assert.strictEqual(mydoc.label, 'mydoc');
94
+ });
95
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * tests/otel.test.mjs — v4.9.0
3
+ *
4
+ * Tests for src/server/otel.mjs. The OpenTelemetry SDK and helpers
5
+ * must:
6
+ * - export a `tracer` that has `startActiveSpan` (works even before
7
+ * `initOtel()` is called, because the no-op global tracer covers
8
+ * the uninitialised case)
9
+ * - return an SDK instance from `initOtel()` and only initialise
10
+ * once across repeated calls
11
+ * - shut down cleanly via `shutdownOtel()`, even when the OTLP
12
+ * endpoint is unreachable
13
+ * - tolerate a malformed endpoint / thrown init error without
14
+ * crashing the importing module
15
+ *
16
+ * Each test loads the module via a fresh URL to defeat Node's ESM
17
+ * module cache; `otel.mjs` uses module-scoped state (the `sdk` and
18
+ * `shuttingDown` lets) and we want each test to see its own clean
19
+ * registry the same way `metrics.test.mjs` does.
20
+ */
21
+
22
+ import test from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+
25
+ const OTEL_URL = '../src/server/otel.mjs';
26
+
27
+ async function loadOtel() {
28
+ const url = `${OTEL_URL}?v=${Date.now()}-${Math.random()}`;
29
+ return import(url);
30
+ }
31
+
32
+ test('tracer is exported and has startActiveSpan', async () => {
33
+ const otel = await loadOtel();
34
+ try {
35
+ assert.equal(typeof otel.tracer, 'object');
36
+ assert.equal(typeof otel.tracer.startActiveSpan, 'function');
37
+ } finally {
38
+ await otel.shutdownOtel();
39
+ }
40
+ });
41
+
42
+ test('initOtel() returns an SDK instance and is idempotent', async () => {
43
+ const otel = await loadOtel();
44
+ try {
45
+ // Before init: isOtelEnabled() is false (fresh module load).
46
+ assert.equal(otel.isOtelEnabled(), false);
47
+ const sdk1 = otel.initOtel({
48
+ // Use an endpoint that won't connect; the test only cares
49
+ // that the SDK object is returned, not that spans export.
50
+ endpoint: 'http://127.0.0.1:1/v1/traces',
51
+ });
52
+ assert.ok(sdk1, 'initOtel should return a non-null SDK instance');
53
+ assert.equal(otel.isOtelEnabled(), true);
54
+
55
+ const sdk2 = otel.initOtel({ endpoint: 'http://127.0.0.1:1/v1/traces' });
56
+ assert.equal(
57
+ sdk1,
58
+ sdk2,
59
+ 'a second initOtel() call must return the cached SDK instance',
60
+ );
61
+ } finally {
62
+ await otel.shutdownOtel();
63
+ }
64
+ });
65
+
66
+ test('shutdownOtel() is callable and resets the SDK cache', async () => {
67
+ const otel = await loadOtel();
68
+ try {
69
+ otel.initOtel({ endpoint: 'http://127.0.0.1:1/v1/traces' });
70
+ assert.equal(otel.isOtelEnabled(), true);
71
+ await otel.shutdownOtel();
72
+ assert.equal(otel.isOtelEnabled(), false);
73
+
74
+ // Calling shutdownOtel again must be a no-op (does not throw).
75
+ await otel.shutdownOtel();
76
+ assert.equal(otel.isOtelEnabled(), false);
77
+ } finally {
78
+ await otel.shutdownOtel();
79
+ }
80
+ });
81
+
82
+ test('graceful degradation when OTLP endpoint is unreachable', async () => {
83
+ // Port 1 on loopback never accepts connections — the exporter will
84
+ // log batch-send errors internally but must not crash the module.
85
+ const otel = await loadOtel();
86
+ try {
87
+ const sdk = otel.initOtel({
88
+ endpoint: 'http://127.0.0.1:1/v1/traces',
89
+ });
90
+ assert.ok(sdk, 'initOtel must succeed even with an unreachable endpoint');
91
+ assert.equal(otel.isOtelEnabled(), true);
92
+
93
+ // Spans created on the live tracer must complete without errors.
94
+ await new Promise((resolve, reject) => {
95
+ otel.tracer.startActiveSpan('test.unreachable_endpoint', async (span) => {
96
+ try {
97
+ span.setAttribute('test.attr', 1);
98
+ span.setStatus({ code: 1 /* OK */ });
99
+ await Promise.resolve();
100
+ resolve();
101
+ } catch (e) {
102
+ reject(e);
103
+ } finally {
104
+ span.end();
105
+ }
106
+ });
107
+ });
108
+
109
+ await otel.shutdownOtel();
110
+ } finally {
111
+ await otel.shutdownOtel();
112
+ }
113
+ });
114
+
115
+ test('spans can be created and ended without errors (no SDK initialised)', async () => {
116
+ // No initOtel call — exercises the no-op tracer path. Route
117
+ // handlers rely on this when OTEL is disabled (the default).
118
+ const otel = await loadOtel();
119
+ assert.equal(otel.isOtelEnabled(), false);
120
+
121
+ const result = await new Promise((resolve, reject) => {
122
+ otel.tracer.startActiveSpan('test.noop', (span) => {
123
+ try {
124
+ // Sanity-check that the no-op span still answers every
125
+ // method without throwing — that's the contract route
126
+ // handlers rely on.
127
+ span.setAttribute('a', 1);
128
+ span.setAttribute('b', 'hello');
129
+ span.addEvent('event-1');
130
+ span.setStatus({ code: 1 });
131
+ resolve('ok');
132
+ } catch (err) {
133
+ reject(err);
134
+ } finally {
135
+ span.end();
136
+ }
137
+ });
138
+ });
139
+ assert.equal(result, 'ok');
140
+
141
+ await otel.shutdownOtel();
142
+ });
143
+
144
+ test('tracer.startActiveSpan callback can be async (returns a Promise)', async () => {
145
+ // The OpenTelemetry spec lets the callback return a Promise;
146
+ // the tracer awaits it. Make sure the no-op implementation
147
+ // (used when the SDK is disabled) honors this.
148
+ const otel = await loadOtel();
149
+ try {
150
+ let observed = null;
151
+ await otel.tracer.startActiveSpan('test.async', async (span) => {
152
+ try {
153
+ observed = await Promise.resolve('inside-span');
154
+ } finally {
155
+ span.end();
156
+ }
157
+ });
158
+ assert.equal(observed, 'inside-span');
159
+ } finally {
160
+ await otel.shutdownOtel();
161
+ }
162
+ });
163
+
164
+ test('initOtel accepts custom serviceName override', async () => {
165
+ const otel = await loadOtel();
166
+ try {
167
+ const sdk = otel.initOtel({
168
+ serviceName: 'bizar-dash-test',
169
+ endpoint: 'http://127.0.0.1:1/v1/traces',
170
+ });
171
+ assert.ok(sdk, 'initOtel with a custom serviceName should succeed');
172
+ await otel.shutdownOtel();
173
+ } finally {
174
+ await otel.shutdownOtel();
175
+ }
176
+ });
177
+
178
+ test('module does not crash when imported (no init, no shutdown)', async () => {
179
+ // Defensive sanity check — every other test file in this repo
180
+ // transitively imports dozens of modules. If a syntax/import
181
+ // error sneaks into otel.mjs, this test catches it before the
182
+ // larger suite blows up somewhere unrelated.
183
+ const otel = await loadOtel();
184
+ assert.equal(typeof otel.initOtel, 'function');
185
+ assert.equal(typeof otel.shutdownOtel, 'function');
186
+ assert.equal(typeof otel.tracer, 'object');
187
+ await otel.shutdownOtel();
188
+ });
@@ -42,6 +42,12 @@ export function showDashHelp() {
42
42
  Note:
43
43
  \`bizar dashboard\` is a deprecated alias for \`bizar dash\` and still
44
44
  works, but new code should use \`bizar dash\`.
45
+
46
+ OpenTelemetry (optional, env vars):
47
+ BIZAR_OTEL=1 Enable OpenTelemetry tracing (off by default)
48
+ OTEL_ENABLED=1 Alias that also opts in
49
+ OTEL_EXPORTER_OTLP_ENDPOINT OTLP HTTP traces endpoint
50
+ (default http://localhost:4318/v1/traces)
45
51
  `);
46
52
  }
47
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "4.7.2",
3
+ "version": "4.9.0",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -78,6 +78,12 @@
78
78
  },
79
79
  "devDependencies": {
80
80
  "@opencode-ai/plugin": "^1.17.7",
81
+ "@opentelemetry/api": "^1.9.0",
82
+ "@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
83
+ "@opentelemetry/resources": "^1.25.0",
84
+ "@opentelemetry/sdk-node": "^0.52.0",
85
+ "@opentelemetry/sdk-trace-node": "^1.25.0",
86
+ "@opentelemetry/semantic-conventions": "^1.25.0",
81
87
  "@testing-library/jest-dom": "^6.9.1",
82
88
  "@testing-library/react": "^16.3.2",
83
89
  "@testing-library/user-event": "^14.6.1",