@polderlabs/bizar 4.4.13 → 4.5.1

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 (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * tests/skills-list.test.mjs
3
+ *
4
+ * Tests for skills-store.mjs — verifies:
5
+ * 1. All skill sources are scanned
6
+ * 2. SKILL.md frontmatter is parsed correctly
7
+ * 3. Skills have required fields
8
+ * 4. Cache invalidation works
9
+ */
10
+ import { describe, it, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
15
+
16
+ const TEST_ROOT = join(tmpdir(), `bizar-skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+
18
+ // Dynamically import so we can set projectRoot before use
19
+ async function getStore() {
20
+ const { skillsStore, setProjectRoot, invalidateCache } = await import('../src/server/skills-store.mjs');
21
+ return { skillsStore, setProjectRoot, invalidateCache };
22
+ }
23
+
24
+ describe('skills-store', () => {
25
+ let skillsStore;
26
+ let setProjectRoot;
27
+ let invalidateCache;
28
+
29
+ // Skill directory fixtures
30
+ let FIXTURE_USER_OPENCODE;
31
+ let FIXTURE_USER_AGENTS;
32
+ let FIXTURE_SHIPPED;
33
+ let FIXTURE_PROJECT;
34
+
35
+ beforeEach(async () => {
36
+ mkdirSync(TEST_ROOT, { recursive: true });
37
+
38
+ // Helper: create skill dir + write SKILL.md in one shot
39
+ const skillFile = (base, name, content) => {
40
+ const dir = join(base, name);
41
+ mkdirSync(dir, { recursive: true });
42
+ writeFileSync(join(dir, 'SKILL.md'), content);
43
+ };
44
+
45
+ // Set up fake shipped directory
46
+ const shipped = join(TEST_ROOT, 'bizar-dash', 'skills');
47
+ mkdirSync(shipped, { recursive: true });
48
+ skillFile(shipped, 'shipped-skill', `---\nname: shipped-display\ndescription: A shipped skill description.\n---\n# Shipped Skill\nBody text.\n`);
49
+ skillFile(shipped, 'bizar', `---\nname: bizar\ndescription: Bizar main skill.\n---\n# Bizar\nAgent routing.\n`);
50
+
51
+ // Set up project-local .agents/skills
52
+ const projectAgents = join(TEST_ROOT, '.agents', 'skills');
53
+ mkdirSync(projectAgents, { recursive: true });
54
+ skillFile(projectAgents, 'project-skill', `---\nname: Project Skill\ndescription: A project-local skill.\n---\n# Project Skill\n`);
55
+ skillFile(projectAgents, 'chat', `---\nname: chat\ndescription: Chat integration.\n---\n# Chat\n`);
56
+
57
+ // Set up project-local .opencode/skills
58
+ const projectOpencode = join(TEST_ROOT, '.opencode', 'skills');
59
+ mkdirSync(projectOpencode, { recursive: true });
60
+ skillFile(projectOpencode, 'usage', `---\nname: usage\ndescription: Usage monitoring.\n---\n# Usage\n`);
61
+
62
+ // Set up shipped path (relative to project root)
63
+ FIXTURE_SHIPPED = shipped;
64
+
65
+ const mod = await getStore();
66
+ skillsStore = mod.skillsStore;
67
+ setProjectRoot = mod.setProjectRoot;
68
+ invalidateCache = mod.invalidateCache;
69
+
70
+ setProjectRoot(TEST_ROOT);
71
+ invalidateCache();
72
+ });
73
+
74
+ afterEach(() => {
75
+ try { rmSync(TEST_ROOT, { recursive: true, force: true }); } catch { /* ignore */ }
76
+ });
77
+
78
+ // ── list() ─────────────────────────────────────────────────────────────────
79
+
80
+ describe('list()', () => {
81
+ it('returns all skills as a flat array', async () => {
82
+ const all = await skillsStore.list();
83
+ assert.ok(Array.isArray(all));
84
+ assert.ok(all.length > 0);
85
+ });
86
+
87
+ it('includes shipped skills from bizar-dash/skills/', async () => {
88
+ const all = await skillsStore.list();
89
+ const shipped = all.filter((s) => s.source === 'shipped');
90
+ assert.ok(shipped.length > 0, 'should have at least one shipped skill');
91
+ const bizar = shipped.find((s) => s.name === 'bizar');
92
+ assert.ok(bizar, 'bizar shipped skill should be present');
93
+ });
94
+
95
+ it('includes project-local skills from .agents/skills/', async () => {
96
+ const all = await skillsStore.list();
97
+ const project = all.filter((s) => s.source === 'project');
98
+ assert.ok(project.length > 0, 'should have project skills');
99
+ });
100
+
101
+ it('every skill has name, description, source, path', async () => {
102
+ const all = await skillsStore.list();
103
+ for (const s of all) {
104
+ assert.ok(typeof s.name === 'string' && s.name.length > 0, `${JSON.stringify(s)} needs a name`);
105
+ assert.ok(typeof s.description === 'string', `${s.name} needs a description string`);
106
+ assert.ok(['shipped', 'user', 'project'].includes(s.source), `${s.name} needs a valid source`);
107
+ assert.ok(typeof s.path === 'string' && s.path.endsWith('SKILL.md'), `${s.name} needs a path ending in SKILL.md`);
108
+ }
109
+ });
110
+
111
+ it('description is truncated to 200 chars', async () => {
112
+ const long = 'A'.repeat(400);
113
+ mkdirSync(join(FIXTURE_SHIPPED, 'long-desc'), { recursive: true });
114
+ writeFileSync(join(FIXTURE_SHIPPED, 'long-desc', 'SKILL.md'), `---\nname: long-desc\ndescription: ${long}\n---\n# Long\n`);
115
+ invalidateCache();
116
+ const all = await skillsStore.list();
117
+ const longSkill = all.find((s) => s.name === 'long-desc');
118
+ assert.ok(longSkill.description.length <= 200, 'description should be truncated to 200 chars');
119
+ });
120
+
121
+ it('display name falls back to H1 heading when frontmatter name is missing', async () => {
122
+ mkdirSync(join(FIXTURE_SHIPPED, 'heading-only'), { recursive: true });
123
+ writeFileSync(join(FIXTURE_SHIPPED, 'heading-only', 'SKILL.md'), `---\ndescription: Has heading but no name field.\n---\n# My Heading Skill\nBody.\n`);
124
+ invalidateCache();
125
+ const all = await skillsStore.list();
126
+ const skill = all.find((s) => s.name === 'My Heading Skill');
127
+ assert.ok(skill, 'should fall back to H1 heading as name');
128
+ });
129
+
130
+ it('display name falls back to directory name when both frontmatter name and H1 are absent', async () => {
131
+ mkdirSync(join(FIXTURE_SHIPPED, 'directory-named-skill'), { recursive: true });
132
+ writeFileSync(join(FIXTURE_SHIPPED, 'directory-named-skill', 'SKILL.md'), `---\ndescription: No name anywhere.\n---\nPlain body without heading.\n`);
133
+ invalidateCache();
134
+ const all = await skillsStore.list();
135
+ const skill = all.find((s) => s.path.endsWith('directory-named-skill/SKILL.md'));
136
+ assert.ok(skill, 'should fall back to directory name');
137
+ });
138
+
139
+ it('returns empty array when no skill directories exist', async () => {
140
+ const empty = join(tmpdir(), `bizar-empty-${Date.now()}`);
141
+ mkdirSync(empty, { recursive: true });
142
+ setProjectRoot(empty);
143
+ invalidateCache();
144
+ const all = await skillsStore.list();
145
+ assert.ok(Array.isArray(all));
146
+ });
147
+ });
148
+
149
+ // ── search() ────────────────────────────────────────────────────────────────
150
+
151
+ describe('search()', () => {
152
+ it('returns all skills when query is empty', async () => {
153
+ const all = await skillsStore.list();
154
+ const results = await skillsStore.search('');
155
+ assert.strictEqual(results.length, all.length);
156
+ });
157
+
158
+ it('filters by name or description substring', async () => {
159
+ const results = await skillsStore.search('bizar');
160
+ assert.ok(results.length > 0, 'should find at least one skill matching bizar');
161
+ // Search matches name OR description, so at least one result should contain 'bizar'
162
+ assert.ok(
163
+ results.some((s) => `${s.name} ${s.description}`.toLowerCase().includes('bizar')),
164
+ 'results should contain bizar in name or description',
165
+ );
166
+ });
167
+
168
+ it('filters by description substring', async () => {
169
+ const results = await skillsStore.search('monitoring');
170
+ assert.ok(results.length > 0, 'should find skill with monitoring in description');
171
+ });
172
+
173
+ it('returns plain strings — no ANSI escape codes in name or description', async () => {
174
+ const results = await skillsStore.search('bizar');
175
+ for (const s of results) {
176
+ assert.ok(!s.name.includes('\x1b'), `name "${s.name}" should not contain ANSI escape codes`);
177
+ assert.ok(!s.description.includes('\x1b'), `description should not contain ANSI codes`);
178
+ }
179
+ });
180
+
181
+ it('matches multi-term queries (AND semantics)', async () => {
182
+ const results = await skillsStore.search('bizar skill');
183
+ for (const s of results) {
184
+ const hay = `${s.name} ${s.description}`.toLowerCase();
185
+ assert.ok(hay.includes('bizar') && hay.includes('skill'), `${s.name} should match both terms`);
186
+ }
187
+ });
188
+
189
+ it('is case-insensitive', async () => {
190
+ const r1 = await skillsStore.search('BIZAR');
191
+ const r2 = await skillsStore.search('bizar');
192
+ const r3 = await skillsStore.search('BiZaR');
193
+ assert.strictEqual(r1.length, r2.length);
194
+ assert.strictEqual(r2.length, r3.length);
195
+ });
196
+ });
197
+
198
+ // ── refresh() ─────────────────────────────────────────────────────────────
199
+
200
+ describe('refresh()', () => {
201
+ it('invalidates cache and rescans', async () => {
202
+ const first = await skillsStore.list();
203
+ assert.ok(first.length > 0);
204
+
205
+ // Add a new skill
206
+ mkdirSync(join(FIXTURE_SHIPPED, 'brand-new'), { recursive: true });
207
+ writeFileSync(join(FIXTURE_SHIPPED, 'brand-new', 'SKILL.md'), `---\nname: Brand New\ndescription: A freshly added skill.\n---\n# Brand New\n`);
208
+ invalidateCache();
209
+
210
+ const after = await skillsStore.list();
211
+ assert.ok(after.length > first.length, 'newly added skill should appear after refresh');
212
+ const found = after.find((s) => s.name === 'Brand New');
213
+ assert.ok(found, 'Brand New skill should be in list');
214
+ });
215
+ });
216
+
217
+ // ── get() ─────────────────────────────────────────────────────────────────
218
+
219
+ describe('get()', () => {
220
+ it('returns a single skill by source and name', async () => {
221
+ const skill = await skillsStore.get('shipped', 'bizar');
222
+ assert.ok(skill, 'should find the bizar skill');
223
+ assert.strictEqual(skill.name, 'bizar');
224
+ assert.strictEqual(skill.source, 'shipped');
225
+ });
226
+
227
+ it('returns null for unknown skill', async () => {
228
+ const skill = await skillsStore.get('shipped', 'nonexistent-skill-xyz');
229
+ assert.strictEqual(skill, null);
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * tests/skills-search.test.mjs
3
+ *
4
+ * Tests for skills search — verifies:
5
+ * 1. Fuzzy search returns correct matches
6
+ * 2. NO ASCII garbage / ANSI codes in search results
7
+ * 3. Multi-term queries work correctly
8
+ * 4. Empty query returns all skills
9
+ */
10
+ import { describe, it, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
15
+
16
+ const TEST_ROOT = join(tmpdir(), `bizar-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+
18
+ async function getStore() {
19
+ const { skillsStore, setProjectRoot, invalidateCache } = await import('../src/server/skills-store.mjs');
20
+ return { skillsStore, setProjectRoot, invalidateCache };
21
+ }
22
+
23
+ describe('skills search', () => {
24
+ let skillsStore;
25
+ let setProjectRoot;
26
+ let invalidateCache;
27
+
28
+ beforeEach(async () => {
29
+ mkdirSync(TEST_ROOT, { recursive: true });
30
+
31
+ const shipped = join(TEST_ROOT, 'bizar-dash', 'skills');
32
+ mkdirSync(shipped, { recursive: true });
33
+
34
+ // Create test skills with realistic names and descriptions
35
+ const fixtures = [
36
+ {
37
+ dir: 'bizar',
38
+ name: 'bizar',
39
+ description: 'Norse-pantheon multi-agent system. Covers Odin routing, agent tiers, cost-aware dispatch.',
40
+ },
41
+ {
42
+ dir: 'agent-baseline',
43
+ name: 'agent-baseline',
44
+ description: 'Always-on rules for every Bizar agent. Covers Semble, Skills CLI, loop guard, parallel execution.',
45
+ },
46
+ {
47
+ dir: 'self-improvement',
48
+ name: 'self-improvement',
49
+ description: 'Self-improvement protocol. Records lessons to .bizar/AGENTS_SELF_IMPROVEMENT.md.',
50
+ },
51
+ {
52
+ dir: 'obsidian',
53
+ name: 'obsidian',
54
+ description: 'Bizar Memory Service using Obsidian-compatible Markdown. Git sync and LightRAG indexing.',
55
+ },
56
+ {
57
+ dir: 'minimax',
58
+ name: 'minimax',
59
+ description: 'MiniMax integration. Covers multi-key rotation, usage tracking, rate limits.',
60
+ },
61
+ {
62
+ dir: 'providers',
63
+ name: 'providers',
64
+ description: 'Provider subsystem. Backup keys, auto-add wizard, provider catalog management.',
65
+ },
66
+ {
67
+ dir: 'chat',
68
+ name: 'chat',
69
+ description: 'Chat and opencode session integration. Real-time SSE streaming, message history.',
70
+ },
71
+ {
72
+ dir: 'usage',
73
+ name: 'usage',
74
+ description: 'Token usage monitoring and cost estimation. MiniMax usage dashboard.',
75
+ },
76
+ {
77
+ dir: 'skills-cli',
78
+ name: 'skills-cli',
79
+ description: 'Skills CLI reference. Discover and install skills from skills.sh ecosystem.',
80
+ },
81
+ {
82
+ dir: 'lightrag',
83
+ name: 'lightrag',
84
+ description: 'LightRAG full-text search integration. Indexing, querying, memory service integration.',
85
+ },
86
+ ];
87
+
88
+ for (const f of fixtures) {
89
+ mkdirSync(join(shipped, f.dir), { recursive: true });
90
+ writeFileSync(
91
+ join(shipped, f.dir, 'SKILL.md'),
92
+ `---\nname: ${f.name}\ndescription: ${f.description}\n---\n# ${f.name}\nBody content.\n`,
93
+ );
94
+ }
95
+
96
+ const mod = await getStore();
97
+ skillsStore = mod.skillsStore;
98
+ setProjectRoot = mod.setProjectRoot;
99
+ invalidateCache = mod.invalidateCache;
100
+
101
+ setProjectRoot(TEST_ROOT);
102
+ invalidateCache();
103
+ });
104
+
105
+ afterEach(() => {
106
+ try { rmSync(TEST_ROOT, { recursive: true, force: true }); } catch { /* ignore */ }
107
+ });
108
+
109
+ // ── ANSI / ASCII garbage check ──────────────────────────────────────────────
110
+
111
+ function assertNoAnsi(label, value) {
112
+ const ansiPattern = /\x1b\[[0-9;]*[a-zA-Z]/;
113
+ assert.ok(
114
+ !ansiPattern.test(value),
115
+ `${label} should not contain ANSI escape codes: ${JSON.stringify(value.slice(0, 80))}`,
116
+ );
117
+ }
118
+
119
+ it('search results contain no ANSI escape codes', async () => {
120
+ const queries = ['', 'bizar', 'skill', 'memory', 'agent', 'provider'];
121
+ for (const q of queries) {
122
+ const results = await skillsStore.search(q);
123
+ for (const s of results) {
124
+ assertNoAnsi(`name (query="${q}")`, s.name);
125
+ assertNoAnsi(`description (query="${q}")`, s.description);
126
+ assertNoAnsi(`path (query="${q}")`, s.path);
127
+ assertNoAnsi(`source (query="${q}")`, s.source);
128
+ }
129
+ }
130
+ });
131
+
132
+ it('list results contain no ANSI escape codes', async () => {
133
+ const all = await skillsStore.list();
134
+ for (const s of all) {
135
+ assertNoAnsi('name', s.name);
136
+ assertNoAnsi('description', s.description);
137
+ assertNoAnsi('path', s.path);
138
+ assertNoAnsi('source', s.source);
139
+ }
140
+ });
141
+
142
+ it('get() result contains no ANSI escape codes', async () => {
143
+ const skill = await skillsStore.get('shipped', 'bizar');
144
+ if (skill) {
145
+ assertNoAnsi('name', skill.name);
146
+ assertNoAnsi('description', skill.description);
147
+ }
148
+ });
149
+
150
+ // ── Search accuracy ─────────────────────────────────────────────────────────
151
+
152
+ it('finds skill by name (case-insensitive)', async () => {
153
+ const results = await skillsStore.search('BIZAR');
154
+ assert.ok(results.length > 0);
155
+ assert.ok(results.some((s) => s.name.toLowerCase() === 'bizar'));
156
+ });
157
+
158
+ it('finds skill by partial name', async () => {
159
+ const results = await skillsStore.search('self-improv');
160
+ assert.ok(results.length > 0);
161
+ assert.ok(results.some((s) => s.name === 'self-improvement'));
162
+ });
163
+
164
+ it('finds skill by description keyword', async () => {
165
+ const results = await skillsStore.search('multi-key rotation');
166
+ assert.ok(results.length > 0);
167
+ assert.ok(results.some((s) => s.name === 'minimax'));
168
+ });
169
+
170
+ it('finds skill by description keyword — lightrag', async () => {
171
+ const results = await skillsStore.search('full-text search');
172
+ assert.ok(results.length > 0);
173
+ assert.ok(results.some((s) => s.name === 'lightrag'));
174
+ });
175
+
176
+ it('returns empty array for non-matching query', async () => {
177
+ const results = await skillsStore.search('zzznonexistent999xyz');
178
+ assert.ok(Array.isArray(results));
179
+ assert.strictEqual(results.length, 0);
180
+ });
181
+
182
+ it('multi-term query: both terms must match', async () => {
183
+ const results = await skillsStore.search('obsidian git');
184
+ for (const s of results) {
185
+ const hay = `${s.name} ${s.description}`.toLowerCase();
186
+ assert.ok(
187
+ hay.includes('obsidian') && hay.includes('git'),
188
+ `${s.name} should match both "obsidian" and "git"`,
189
+ );
190
+ }
191
+ });
192
+
193
+ it('whitespace-only query returns all', async () => {
194
+ const all = await skillsStore.list();
195
+ const results = await skillsStore.search(' ');
196
+ assert.strictEqual(results.length, all.length);
197
+ });
198
+
199
+ // ── Result shape ────────────────────────────────────────────────────────────
200
+
201
+ it('search result has required fields: name, description, source, path', async () => {
202
+ const results = await skillsStore.search('bizar');
203
+ assert.ok(results.length > 0);
204
+ for (const s of results) {
205
+ assert.ok(typeof s.name === 'string' && s.name.length > 0, 'name required');
206
+ assert.ok(typeof s.description === 'string', 'description required');
207
+ assert.ok(['shipped', 'user', 'project'].includes(s.source), 'valid source required');
208
+ assert.ok(typeof s.path === 'string' && s.path.endsWith('SKILL.md'), 'path required');
209
+ }
210
+ });
211
+
212
+ it('empty query returns all skills with all fields', async () => {
213
+ const results = await skillsStore.search('');
214
+ assert.ok(results.length > 0);
215
+ for (const s of results) {
216
+ assert.ok(typeof s.name === 'string');
217
+ assert.ok(typeof s.description === 'string');
218
+ assert.ok(typeof s.source === 'string');
219
+ assert.ok(typeof s.path === 'string');
220
+ }
221
+ });
222
+ });
@@ -46,7 +46,7 @@ function makeProjectRoot() {
46
46
  }
47
47
 
48
48
  function seedArtifact(projectRoot, slug, { comments = [], answers = [], status = 'draft' } = {}) {
49
- const dir = join(projectRoot, 'artifacts', slug);
49
+ const dir = join(projectRoot, '.bizar', 'artifacts', slug);
50
50
  mkdirSync(dir, { recursive: true });
51
51
  const meta = {
52
52
  slug,
@@ -85,7 +85,7 @@ test('submitFeedback creates feedback.md', () => {
85
85
  const result = artifactsStore.submitFeedback(slug, { answers: [] }, root);
86
86
  assert.equal(result.ok, true);
87
87
  assert.equal(result.slug, slug);
88
- const feedbackPath = join(root, 'artifacts', slug, 'feedback.md');
88
+ const feedbackPath = join(root, '.bizar', 'artifacts', slug, 'feedback.md');
89
89
  assert.ok(existsSync(feedbackPath), 'feedback.md should be created');
90
90
  const content = readFileSync(feedbackPath, 'utf8');
91
91
  assert.ok(content.includes('glyph: ' + slug), 'frontmatter glyph line');
@@ -107,7 +107,7 @@ test('submitFeedback updates meta.json status to "review"', () => {
107
107
  seedArtifact(root, slug, { status: 'draft' });
108
108
  const result = artifactsStore.submitFeedback(slug, { answers: [] }, root);
109
109
  assert.equal(result.ok, true);
110
- const meta = JSON.parse(readFileSync(join(root, 'artifacts', slug, 'meta.json'), 'utf8'));
110
+ const meta = JSON.parse(readFileSync(join(root, '.bizar', 'artifacts', slug, 'meta.json'), 'utf8'));
111
111
  assert.equal(meta.status, 'review', 'meta.status should be "review"');
112
112
  } finally {
113
113
  rmSync(root, { recursive: true, force: true });
@@ -123,7 +123,7 @@ test('submitFeedback updates meta.json lastEdited', () => {
123
123
  const result = artifactsStore.submitFeedback(slug, { answers: [] }, root);
124
124
  const after = Date.now();
125
125
  assert.equal(result.ok, true);
126
- const meta = JSON.parse(readFileSync(join(root, 'artifacts', slug, 'meta.json'), 'utf8'));
126
+ const meta = JSON.parse(readFileSync(join(root, '.bizar', 'artifacts', slug, 'meta.json'), 'utf8'));
127
127
  const ts = Date.parse(meta.lastEdited);
128
128
  assert.ok(Number.isFinite(ts), 'lastEdited should be a parseable timestamp');
129
129
  assert.ok(ts >= before && ts <= after, `lastEdited ${meta.lastEdited} should be in [${before}, ${after}]`);
@@ -208,7 +208,7 @@ test('submitFeedback falls back to comments.json when canvas has none', () => {
208
208
  const slug = 'glyph-comments-json-fallback';
209
209
  // Seed canvas with empty comments array, comments.json with one comment.
210
210
  seedArtifact(root, slug);
211
- const dir = join(root, 'artifacts', slug);
211
+ const dir = join(root, '.bizar', 'artifacts', slug);
212
212
  // Replace plan.json with empty comments, populate comments.json.
213
213
  const canvas = JSON.parse(readFileSync(join(dir, 'plan.json'), 'utf8'));
214
214
  canvas.comments = [];
@@ -339,7 +339,7 @@ test('POST /api/artifacts/:slug/submit persists feedback.md on disk', async () =
339
339
  body: JSON.stringify({ answers: [] }),
340
340
  });
341
341
  assert.equal(res.status, 200);
342
- const feedbackPath = join(root, 'artifacts', slug, 'feedback.md');
342
+ const feedbackPath = join(root, '.bizar', 'artifacts', slug, 'feedback.md');
343
343
  assert.ok(existsSync(feedbackPath), 'feedback.md should exist on disk');
344
344
  const content = readFileSync(feedbackPath, 'utf8');
345
345
  assert.ok(content.startsWith('---'), 'starts with YAML frontmatter');