kiku-agent 1.3.1 → 1.3.3

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 (232) hide show
  1. package/README.md +69 -12
  2. package/package.json +5 -12
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/AGENTS.md +228 -0
  18. package/server/agent/eval/analyze.py +684 -0
  19. package/server/agent/eval/gates.json +15 -0
  20. package/server/agent/eval/modules/analysis/compare.js +198 -0
  21. package/server/agent/eval/modules/analysis/gates.js +72 -0
  22. package/server/agent/eval/modules/analysis/judge.js +70 -0
  23. package/server/agent/eval/modules/core/cli.js +453 -0
  24. package/server/agent/eval/modules/core/config.js +60 -0
  25. package/server/agent/eval/modules/core/store.js +236 -0
  26. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  27. package/server/agent/eval/modules/evaluation/runner.js +288 -0
  28. package/server/agent/eval/modules/evaluation/scoring.js +363 -0
  29. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  30. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  31. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  32. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  33. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  34. package/server/agent/eval/modules/ui/evalUi.js +4799 -0
  35. package/server/agent/eval/modules/utils/smokes.js +119 -0
  36. package/server/agent/eventBus.js +1 -0
  37. package/server/agent/ink/main.jsx +196 -191
  38. package/server/agent/jobManager.js +11 -1
  39. package/server/agent/lanDiscovery.js +721 -23
  40. package/server/agent/llmClient.js +317 -71
  41. package/server/agent/lmStudioWakeup.js +517 -65
  42. package/server/agent/locationService.js +797 -0
  43. package/server/agent/mcpQuizData.js +509 -0
  44. package/server/agent/memory/contextAllocator.js +19 -7
  45. package/server/agent/memory.js +3 -1
  46. package/server/agent/modelCompatibility.js +352 -0
  47. package/server/agent/modelDefaults.js +1 -0
  48. package/server/agent/modelManager.js +46 -2
  49. package/server/agent/newsWatch.js +9 -3
  50. package/server/agent/processManager.js +229 -0
  51. package/server/agent/prompts.js +174 -8
  52. package/server/agent/routingInterceptor.js +9 -1
  53. package/server/agent/sanitizer.js +41 -6
  54. package/server/agent/searchQueryPlanner.js +202 -0
  55. package/server/agent/session.js +59 -4
  56. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  57. package/server/agent/tests/agentFollowUps.test.js +253 -0
  58. package/server/agent/tests/agentMusicControl.test.js +245 -0
  59. package/server/agent/tests/apiRouter.test.js +108 -0
  60. package/server/agent/tests/architectureFixes.test.js +81 -0
  61. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  62. package/server/agent/tests/autoContext.test.js +176 -0
  63. package/server/agent/tests/autoResearch.test.js +64 -0
  64. package/server/agent/tests/bilibiliEmbed.test.js +296 -0
  65. package/server/agent/tests/catalogSync.test.js +130 -0
  66. package/server/agent/tests/chatHardening.test.js +121 -0
  67. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  68. package/server/agent/tests/citations.test.js +180 -0
  69. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  70. package/server/agent/tests/clarifyingQuestions.test.js +627 -0
  71. package/server/agent/tests/cliAutomation.test.js +158 -0
  72. package/server/agent/tests/cloudProviders.test.js +322 -0
  73. package/server/agent/tests/commandInput.test.js +24 -0
  74. package/server/agent/tests/commands.test.js +506 -0
  75. package/server/agent/tests/contextIntelligence.test.js +209 -0
  76. package/server/agent/tests/conversationSafety.test.js +53 -0
  77. package/server/agent/tests/cpuMetrics.test.js +101 -0
  78. package/server/agent/tests/decisionAndSearchEnhancements.test.js +167 -0
  79. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  80. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  81. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  82. package/server/agent/tests/edgeCasesScenarios.test.js +131 -0
  83. package/server/agent/tests/engineService.test.js +436 -0
  84. package/server/agent/tests/engineUnload.test.js +94 -0
  85. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  86. package/server/agent/tests/envBootstrap.test.js +87 -0
  87. package/server/agent/tests/envCommand.test.js +284 -0
  88. package/server/agent/tests/errorHandling.test.js +80 -0
  89. package/server/agent/tests/errors.test.js +13 -0
  90. package/server/agent/tests/evalAnalyze.test.js +54 -0
  91. package/server/agent/tests/evalCommand.test.js +54 -0
  92. package/server/agent/tests/evalCorpus.test.js +126 -0
  93. package/server/agent/tests/evalHarness.test.js +62 -0
  94. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  95. package/server/agent/tests/evalMetrics.test.js +320 -0
  96. package/server/agent/tests/evalStore.test.js +190 -0
  97. package/server/agent/tests/evalStudio.test.js +179 -0
  98. package/server/agent/tests/feedbackLearning.test.js +186 -0
  99. package/server/agent/tests/fetchApi.test.js +99 -0
  100. package/server/agent/tests/firstRun.test.js +44 -0
  101. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  102. package/server/agent/tests/hardwareTiers.test.js +206 -0
  103. package/server/agent/tests/hybridWebSearch.test.js +194 -0
  104. package/server/agent/tests/judgeService.test.js +251 -0
  105. package/server/agent/tests/lanDiscovery.test.js +324 -0
  106. package/server/agent/tests/lanInterfaces.test.js +510 -0
  107. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  108. package/server/agent/tests/locationTool.test.js +278 -0
  109. package/server/agent/tests/mcpClient.test.js +201 -0
  110. package/server/agent/tests/memoryRoute.test.js +173 -0
  111. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  112. package/server/agent/tests/modelCompatibility.test.js +148 -0
  113. package/server/agent/tests/modelManager.test.js +141 -0
  114. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  115. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  116. package/server/agent/tests/newCommands.test.js +347 -0
  117. package/server/agent/tests/newCommandsTui.test.js +141 -0
  118. package/server/agent/tests/newTools.test.js +113 -0
  119. package/server/agent/tests/newsIntelligence.test.js +178 -0
  120. package/server/agent/tests/nextGenArchitecture.test.js +101 -0
  121. package/server/agent/tests/offlineInput.test.js +217 -0
  122. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  123. package/server/agent/tests/openDomainSearch.test.js +121 -0
  124. package/server/agent/tests/packagingGuard.test.js +57 -0
  125. package/server/agent/tests/presentation.test.js +61 -0
  126. package/server/agent/tests/processManager.test.js +144 -0
  127. package/server/agent/tests/providerPersistence.test.js +44 -0
  128. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  129. package/server/agent/tests/queryDetails.test.js +126 -0
  130. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  131. package/server/agent/tests/realWorldValidation.test.js +391 -0
  132. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  133. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  134. package/server/agent/tests/sanitizer.test.js +37 -0
  135. package/server/agent/tests/scenariosData.js +378 -0
  136. package/server/agent/tests/schemaRouting.test.js +54 -0
  137. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  138. package/server/agent/tests/securityAuditRemediation.test.js +221 -0
  139. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  140. package/server/agent/tests/securityHardening.test.js +146 -0
  141. package/server/agent/tests/securitySanitizer.test.js +38 -0
  142. package/server/agent/tests/sessionAndCopy.test.js +267 -0
  143. package/server/agent/tests/sidebarControls.test.js +135 -0
  144. package/server/agent/tests/statefulMemory.test.js +277 -0
  145. package/server/agent/tests/subagent.test.js +216 -0
  146. package/server/agent/tests/subagentProgress.test.js +109 -0
  147. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  148. package/server/agent/tests/suggestions.test.js +199 -0
  149. package/server/agent/tests/suggestionsTui.test.js +251 -0
  150. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  151. package/server/agent/tests/terminalImage.test.js +80 -0
  152. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  153. package/server/agent/tests/terminalText.test.js +133 -0
  154. package/server/agent/tests/textProtocolLoop.test.js +197 -0
  155. package/server/agent/tests/textToolParsing.test.js +187 -0
  156. package/server/agent/tests/theme.test.js +26 -0
  157. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  158. package/server/agent/tests/toolRoutingRegressions.test.js +160 -0
  159. package/server/agent/tests/tools.test.js +259 -0
  160. package/server/agent/tests/unifiedEndpoints.test.js +159 -0
  161. package/server/agent/tests/v110Features.test.js +262 -0
  162. package/server/agent/tests/versionBump.test.js +69 -0
  163. package/server/agent/tests/vramEstimator.test.js +595 -0
  164. package/server/agent/tests/webChatDocs.test.js +199 -0
  165. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  166. package/server/agent/tests/webChatInspection.test.js +551 -0
  167. package/server/agent/tests/webChatMcp.test.js +94 -0
  168. package/server/agent/tests/webChatMemory.test.js +112 -0
  169. package/server/agent/tests/webChatSecurity.test.js +169 -0
  170. package/server/agent/tests/webExtract.test.js +284 -0
  171. package/server/agent/tests/webSources.test.js +281 -0
  172. package/server/agent/textToolProtocol.js +173 -46
  173. package/server/agent/toolRouter.js +318 -44
  174. package/server/agent/tools.js +877 -96
  175. package/server/agent/ui.js +39 -34
  176. package/server/agent/vramService.js +35 -54
  177. package/server/agent/webSearch.js +441 -162
  178. package/server/agent/websearch/orchestrator.js +15 -5
  179. package/server/agent/websearch/resilience/cache.js +9 -2
  180. package/server/agent/websearch/security/ssrf.js +41 -10
  181. package/server/agent/youtubeAuth.js +5 -20
  182. package/server/apiRouter.js +5558 -4422
  183. package/server/eval/judgeRubrics.js +110 -0
  184. package/server/eval/judgeService.js +290 -0
  185. package/server/eval/runBenchmark.js +182 -0
  186. package/server/mcp/data/discography.js +2 -2
  187. package/server/mcp/examples/antigravity.example.json +17 -0
  188. package/server/mcp/examples/librechat.example.yaml +16 -0
  189. package/server/mcp/handler.js +8 -4
  190. package/server/mcp/security.js +220 -21
  191. package/server/mcp/server.js +2 -1
  192. package/server/mcp/tests/connection.test.js +162 -0
  193. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  194. package/server/mcp/tools.js +7 -1
  195. package/server/supabase_migration_prefix.sql +102 -0
  196. package/server/supabase_schema.sql +63 -0
  197. package/server/web-chat/core/connection.js +179 -218
  198. package/server/web-chat/core/conversationContext.js +9 -2
  199. package/server/web-chat/core/directCatalogEngine.js +52 -28
  200. package/server/web-chat/core/dramaKnowledge.js +33 -22
  201. package/server/web-chat/core/followUpEngine.js +735 -81
  202. package/server/web-chat/core/futureEventGuard.js +5 -1
  203. package/server/web-chat/core/musicControlAction.js +70 -0
  204. package/server/web-chat/core/security.js +32 -21
  205. package/server/web-chat/core/toolInspector.js +6 -6
  206. package/server/web-chat/core/urlGuard.js +45 -0
  207. package/server/web-chat/router.js +204 -157
  208. package/server/web-chat/routes/chatRoute.js +885 -488
  209. package/server/web-chat/routes/engineRoute.js +270 -0
  210. package/server/web-chat/routes/exportRoute.js +1 -1
  211. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  212. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  213. package/server/web-chat/routes/mcpRoute.js +386 -0
  214. package/server/web-chat/routes/memoryRoute.js +253 -0
  215. package/server/web-chat/routes/providerRoute.js +49 -16
  216. package/server/web-chat/routes/staticRoute.js +306 -17
  217. package/server/web-chat/routes/statusRoute.js +194 -73
  218. package/server/web-chat/routes/suggestRoute.js +11 -3
  219. package/server/web-chat/routes/verifyRoute.js +26 -4
  220. package/server/web-chat/server.js +7 -6
  221. package/server/web-chat/ui/docsBuilder.js +3558 -0
  222. package/server/web-chat/ui/htmlBuilder.js +12 -0
  223. package/server/web-chat/ui/scripts/client.js +3401 -1302
  224. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  225. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  226. package/server/web-chat/ui/styles/lan-card.css +165 -0
  227. package/server/web-chat/ui/styles/main.css +4289 -447
  228. package/server/web-chat/ui/sw.js +64 -0
  229. package/server/web-chat/ui/templates/body.html +407 -281
  230. package/src/data/awardsData.js +146 -146
  231. package/src/data/musicVideoData.js +6 -4
  232. package/src/lib/vram/estimator.js +195 -19
@@ -0,0 +1,251 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+
5
+ import {
6
+ getCategoryBadge,
7
+ formatWorkerProgressMatrix,
8
+ formatExecutiveFindings,
9
+ stripAnsi,
10
+ } from '../ui.js';
11
+ import {
12
+ renderSuggestionStudioView,
13
+ promptInteractiveSuggestion,
14
+ SUGGESTION_STUDIO_TABS,
15
+ } from '../suggestionTui.js';
16
+ import { routeCommand } from '../commands.js';
17
+
18
+ const SAMPLE_SUGGESTIONS = [
19
+ {
20
+ id: 1,
21
+ category: 'DRAMA',
22
+ question: "What are Ju Jingyi's upcoming 2026/2025 drama projects?",
23
+ description: "Detailed breakdown of upcoming dramas and broadcasting platforms.",
24
+ tools: ['search_works', 'web_search'],
25
+ depth: 'Deep Analysis',
26
+ estimatedSeconds: 3,
27
+ },
28
+ {
29
+ id: 2,
30
+ category: 'MUSIC',
31
+ question: "What are the most popular OSTs and solo singles released by Ju Jingyi?",
32
+ description: "Discography highlights from In Blossom and Legend of Yunxi.",
33
+ tools: ['search_discography', 'search_videos'],
34
+ depth: 'Media Scan',
35
+ estimatedSeconds: 2,
36
+ },
37
+ {
38
+ id: 3,
39
+ category: 'FASHION',
40
+ question: "What are Ju Jingyi's major luxury brand ambassadorships in 2025-2026?",
41
+ description: "Overview of Versace Global Brand Ambassador role and endorsements.",
42
+ tools: ['get_profile_and_fandom'],
43
+ depth: 'Portfolio Scan',
44
+ estimatedSeconds: 2,
45
+ },
46
+ ];
47
+
48
+ test('getCategoryBadge returns distinctive icons and colors for all 6 categories', () => {
49
+ const drama = getCategoryBadge('DRAMA');
50
+ assert.equal(drama.icon, '🌸');
51
+ assert.equal(drama.label, 'DRAMA');
52
+ assert.ok(drama.color);
53
+
54
+ const music = getCategoryBadge('MUSIC');
55
+ assert.equal(music.icon, '🎵');
56
+ assert.equal(music.label, 'MUSIC');
57
+
58
+ const fashion = getCategoryBadge('FASHION');
59
+ assert.equal(fashion.icon, '✨');
60
+ assert.equal(fashion.label, 'FASHION');
61
+
62
+ const awards = getCategoryBadge('MILESTONE');
63
+ assert.equal(awards.icon, '🌸');
64
+ assert.equal(awards.label, 'AWARDS');
65
+
66
+ const analysis = getCategoryBadge('ANALYSIS');
67
+ assert.equal(analysis.icon, '📖');
68
+ assert.equal(analysis.label, 'ANALYSIS');
69
+
70
+ const trivia = getCategoryBadge('TRIVIA');
71
+ assert.equal(trivia.icon, '💬');
72
+ assert.equal(trivia.label, 'TRIVIA');
73
+
74
+ const unknown = getCategoryBadge('OTHER');
75
+ assert.equal(unknown.icon, '✨');
76
+ assert.equal(unknown.label, 'OTHER');
77
+ });
78
+
79
+ test('renderSuggestionStudioView renders complete interactive studio frame', () => {
80
+ const rows = renderSuggestionStudioView({
81
+ suggestions: SAMPLE_SUGGESTIONS,
82
+ selectedIndex: 0,
83
+ selectedIds: new Set([1]),
84
+ activeCategory: 'ALL',
85
+ termWidth: 80,
86
+ });
87
+
88
+ assert.ok(rows.length > 5);
89
+ const plainText = rows.map(r => stripAnsi(r)).join('\n');
90
+
91
+
92
+ assert.ok(plainText.includes('🌸 KIKU SUGGESTION STUDIO'));
93
+ assert.ok(plainText.includes('DRAMA'));
94
+ assert.ok(plainText.includes('MUSIC'));
95
+ assert.ok(plainText.includes('FASHION'));
96
+
97
+
98
+ assert.ok(plainText.includes('❯ [◉] 1.'));
99
+ assert.ok(plainText.includes('[ ] 2.'));
100
+
101
+
102
+ assert.ok(plainText.includes('Focus:'));
103
+ assert.ok(plainText.includes('Tools:'));
104
+ assert.ok(plainText.includes('Est:'));
105
+
106
+
107
+ assert.ok(plainText.includes('[↑/↓] Nav'));
108
+ assert.ok(plainText.includes('[Enter] Launch Batch (1)'));
109
+ });
110
+
111
+ test('renderSuggestionStudioView correctly filters suggestions by category tab', () => {
112
+ const dramaRows = renderSuggestionStudioView({
113
+ suggestions: SAMPLE_SUGGESTIONS,
114
+ selectedIndex: 0,
115
+ selectedIds: new Set(),
116
+ activeCategory: 'DRAMA',
117
+ termWidth: 100,
118
+ });
119
+
120
+ const plainText = dramaRows.map(r => stripAnsi(r)).join('\n');
121
+ assert.ok(plainText.includes('upcoming 2026/2025 drama'));
122
+ assert.ok(!plainText.includes('popular OSTs and solo singles'));
123
+
124
+
125
+ const emptyRows = renderSuggestionStudioView({
126
+ suggestions: SAMPLE_SUGGESTIONS,
127
+ selectedIndex: 0,
128
+ selectedIds: new Set(),
129
+ activeCategory: 'TRIVIA',
130
+ termWidth: 80,
131
+ });
132
+ const emptyText = emptyRows.map(r => stripAnsi(r)).join('\n');
133
+ assert.ok(emptyText.includes('No suggestions found under TRIVIA'));
134
+ });
135
+
136
+ test('promptInteractiveSuggestion exits immediately on non-TTY without hanging', async () => {
137
+ const mockStdin = new EventEmitter();
138
+ mockStdin.isTTY = false;
139
+ const mockStdout = { isTTY: false, columns: 80, write: () => {} };
140
+
141
+ const res = await promptInteractiveSuggestion(SAMPLE_SUGGESTIONS, {
142
+ stdin: mockStdin,
143
+ stdout: mockStdout,
144
+ });
145
+
146
+ assert.equal(res.action, 'display');
147
+ assert.equal(res.targets, null);
148
+ });
149
+
150
+ test('formatWorkerProgressMatrix renders unified concurrent progress table', () => {
151
+ const workers = [
152
+ { index: 1, category: 'DRAMA', state: 'running', currentTool: 'search_works', question: 'Upcoming dramas?' },
153
+ { index: 2, category: 'MUSIC', state: 'complete', currentTool: '', question: 'Popular OSTs?' },
154
+ { index: 3, category: 'FASHION', state: 'error', currentTool: '', question: 'Versace role?' },
155
+ ];
156
+
157
+ const rows = formatWorkerProgressMatrix({
158
+ workers,
159
+ batchId: 'batch_test_1',
160
+ elapsed: '1.8',
161
+ termWidth: 80,
162
+ });
163
+
164
+ assert.ok(rows.length >= 5);
165
+ const plainText = rows.map(r => stripAnsi(r)).join('\n');
166
+
167
+ assert.ok(plainText.includes('CONCURRENT RESEARCH SUBAGENTS'));
168
+ assert.ok(plainText.includes('3 Workers • 1.8s'));
169
+ assert.ok(plainText.includes('W1 [🌸 DRAMA]'));
170
+ assert.ok(plainText.includes('Working'));
171
+ assert.ok(plainText.includes('W2 [🎵 MUSIC]'));
172
+ assert.ok(plainText.includes('Done'));
173
+ assert.ok(plainText.includes('W3 [✨ FASHION]'));
174
+ assert.ok(plainText.includes('Error'));
175
+ });
176
+
177
+ test('formatExecutiveFindings synthesizes findings, takeaways, and grounded citations', () => {
178
+ const batchResult = {
179
+ tasksCount: 2,
180
+ successfulCount: 2,
181
+ elapsed: '2.4',
182
+ results: [
183
+ {
184
+ index: 1,
185
+ category: 'DRAMA',
186
+ question: 'Upcoming dramas?',
187
+ answer: 'Ju Jingyi has multiple upcoming drama productions announced.',
188
+ toolCalls: [{ name: 'search_works' }],
189
+ iterations: 2,
190
+ },
191
+ {
192
+ index: 2,
193
+ category: 'FASHION',
194
+ question: 'Versace role?',
195
+ answer: 'Ju Jingyi was named Versace Global Brand Ambassador.',
196
+ toolCalls: [{ name: 'get_profile_and_fandom' }],
197
+ iterations: 1,
198
+ },
199
+ ],
200
+ };
201
+
202
+ const rows = formatExecutiveFindings({ batchResult, termWidth: 80 });
203
+ assert.ok(rows.length > 5);
204
+ const plainText = rows.map(r => stripAnsi(r)).join('\n');
205
+
206
+ assert.ok(plainText.includes('KIKU EXECUTIVE RESEARCH FINDINGS'));
207
+ assert.ok(plainText.includes('2/2 Succeeded • 2.4s'));
208
+ assert.ok(plainText.includes('[Worker 1] DRAMA: Upcoming dramas?'));
209
+ assert.ok(plainText.includes('Grounded Evidence: 1 tool calls executed'));
210
+ assert.ok(plainText.includes('[Worker 2] FASHION: Versace role?'));
211
+ assert.ok(plainText.includes('/session export'));
212
+ });
213
+
214
+ test('Commands: /suggestion executes cleanly in non-interactive routeCommand', async () => {
215
+ let generateCalled = false;
216
+ let batchCalled = false;
217
+
218
+ const mockAgent = {
219
+ async generateSuggestions() {
220
+ generateCalled = true;
221
+ return SAMPLE_SUGGESTIONS;
222
+ },
223
+ async runSuggestionBatch(targets) {
224
+ batchCalled = true;
225
+ return {
226
+ batchId: 'batch_test',
227
+ tasksCount: 1,
228
+ successfulCount: 1,
229
+ elapsed: '0.5',
230
+ results: [
231
+ {
232
+ index: 1,
233
+ category: 'DRAMA',
234
+ question: 'Upcoming dramas?',
235
+ answer: 'Yang Caiwei in In Blossom received critical acclaim.',
236
+ toolCalls: [{ name: 'search_works', args: {} }],
237
+ iterations: 1,
238
+ },
239
+ ],
240
+ };
241
+ },
242
+ };
243
+
244
+ const listRes = await routeCommand('/suggestion', { agent: mockAgent, sub: '', extra: '' });
245
+ assert.equal(listRes, 'HANDLED');
246
+ assert.equal(generateCalled, true);
247
+
248
+ const runRes = await routeCommand('/suggestion run 1', { agent: mockAgent, sub: 'run', extra: '1' });
249
+ assert.equal(runRes, 'HANDLED');
250
+ assert.equal(batchCalled, true);
251
+ });
@@ -0,0 +1,179 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ estimateTokens,
8
+ calculateTokenCost,
9
+ formatCostUsd,
10
+ formatTokenCount,
11
+ MODEL_PRICING
12
+ } from '../telemetry.js';
13
+ import {
14
+ BUILTIN_PROFILES,
15
+ loadProfiles,
16
+ getProfile,
17
+ getActiveProfile,
18
+ setActiveProfile,
19
+ createOrUpdateProfile,
20
+ deleteProfile,
21
+ listProfiles
22
+ } from '../profiles.js';
23
+ import { parseCliArgs } from '../cli.js';
24
+ import { routeCommand } from '../commands.js';
25
+ import { PACKAGE_VERSION } from '../version.js';
26
+
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+ const cliPath = path.resolve(__dirname, '../cli.js');
30
+
31
+ test('Telemetry: estimateTokens calculates token heuristics for various scripts', () => {
32
+ assert.equal(estimateTokens(''), 0);
33
+ assert.equal(estimateTokens(null), 0);
34
+
35
+ const enTokens = estimateTokens('Ju Jingyi is an accomplished artist and singer.');
36
+ assert.ok(enTokens >= 8 && enTokens <= 20, `enTokens ${enTokens} out of expected range`);
37
+
38
+ const zhTokens = estimateTokens('鞠婧祎中国女演员歌手');
39
+ assert.ok(zhTokens >= 10 && zhTokens <= 25, `zhTokens ${zhTokens} out of expected range`);
40
+
41
+ const thTokens = estimateTokens('จวีจิ้งอี นักแสดงและนักร้องสาว');
42
+ assert.ok(thTokens >= 15 && thTokens <= 40, `thTokens ${thTokens} out of expected range`);
43
+ });
44
+
45
+ test('Telemetry: calculateTokenCost calculates pricing accurately across providers', () => {
46
+ const lmCost = calculateTokenCost('lmstudio', 'google/gemma-4-e4b', 1000, 500);
47
+ assert.equal(lmCost, 0);
48
+
49
+ const groqCost = calculateTokenCost('groq', 'openai/gpt-oss-20b', 1000, 1000);
50
+ assert.ok(groqCost > 0 && groqCost < 0.01, `Groq cost: ${groqCost}`);
51
+
52
+ const deepseekCost = calculateTokenCost('deepseek', 'deepseek-v4-flash', 10000, 5000);
53
+ assert.ok(deepseekCost > 0 && deepseekCost < 0.05, `DeepSeek cost: ${deepseekCost}`);
54
+
55
+ const openaiCost = calculateTokenCost('openai', 'gpt-5.6-luna', 1000, 1000);
56
+ assert.ok(openaiCost > 0, `OpenAI cost: ${openaiCost}`);
57
+
58
+ const geminiCost = calculateTokenCost('gemini', 'gemini-3.7-flash', 1000, 1000);
59
+ assert.ok(geminiCost > 0, `Gemini cost: ${geminiCost}`);
60
+ });
61
+
62
+ test('Telemetry: formatCostUsd and formatTokenCount format numbers cleanly', () => {
63
+ assert.equal(formatCostUsd(0), '$0.00 (Local)');
64
+ assert.equal(formatCostUsd(0.00005), '<$0.0001');
65
+ assert.equal(formatCostUsd(0.0012), '$0.0012');
66
+ assert.equal(formatCostUsd(1.5), '$1.5000');
67
+
68
+ assert.equal(formatTokenCount(500), '500');
69
+ assert.equal(formatTokenCount(1500), '1,500');
70
+ assert.equal(formatTokenCount(2500000), '2,500,000');
71
+ });
72
+
73
+ test('Profiles: BUILTIN_PROFILES has required production profiles', () => {
74
+ const expectedProfiles = ['default', 'fast', 'deep-reasoning', 'cloud', 'gemini'];
75
+ for (const name of expectedProfiles) {
76
+ assert.ok(BUILTIN_PROFILES[name], `Missing built-in profile: ${name}`);
77
+ assert.ok(BUILTIN_PROFILES[name].provider);
78
+ assert.ok(BUILTIN_PROFILES[name].model);
79
+ assert.ok(BUILTIN_PROFILES[name].description);
80
+ }
81
+ });
82
+
83
+ test('Profiles: lifecycle methods support create, activate, query, and delete', () => {
84
+ const initialList = listProfiles();
85
+ assert.ok(initialList.length >= 5);
86
+
87
+ const active = getActiveProfile();
88
+ assert.ok(active && active.name);
89
+
90
+ const switched = setActiveProfile('fast');
91
+ assert.equal(switched.name, 'fast');
92
+ assert.equal(getActiveProfile().name, 'fast');
93
+
94
+ const custom = createOrUpdateProfile('unit-test-profile', {
95
+ provider: 'openai',
96
+ model: 'gpt-5.6-luna',
97
+ temperature: 0.5,
98
+ description: 'Temporary profile for unit tests'
99
+ });
100
+ assert.equal(custom.name, 'unit-test-profile');
101
+ assert.equal(custom.provider, 'openai');
102
+ assert.equal(custom.model, 'gpt-5.6-luna');
103
+
104
+ const retrieved = getProfile('unit-test-profile');
105
+ assert.equal(retrieved.name, 'unit-test-profile');
106
+
107
+ assert.throws(() => {
108
+ deleteProfile('default');
109
+ }, /Cannot delete built-in profile/);
110
+
111
+ const deleted = deleteProfile('unit-test-profile');
112
+ assert.equal(deleted, true);
113
+ assert.equal(getProfile('unit-test-profile'), null);
114
+
115
+ setActiveProfile('default');
116
+ assert.equal(getActiveProfile().name, 'default');
117
+ });
118
+
119
+ test('CLI: parseCliArgs handles --profile flag', () => {
120
+ const opts1 = parseCliArgs(['--profile', 'fast']);
121
+ assert.equal(opts1.profile, 'fast');
122
+
123
+ const opts2 = parseCliArgs(['-q', 'what is in blossom', '--profile', 'deep-reasoning', '--output', 'json']);
124
+ assert.equal(opts2.profile, 'deep-reasoning');
125
+ assert.equal(opts2.output, 'json');
126
+ assert.equal(opts2.query, 'what is in blossom');
127
+ });
128
+
129
+ test('CLI: headless JSON output contains token metrics and cost accounting', () => {
130
+ const res = spawnSync(process.execPath, [
131
+ cliPath,
132
+ '--url', 'http://127.0.0.1:9999/v1',
133
+ '-q', 'test query',
134
+ '--output', 'json',
135
+ '--profile', 'fast'
136
+ ], { encoding: 'utf8' });
137
+
138
+ assert.equal(res.status, 2);
139
+ const payload = JSON.parse(res.stdout);
140
+ assert.equal(payload.version, PACKAGE_VERSION);
141
+ assert.equal(payload.status, 'error');
142
+ assert.ok(payload.metrics, 'Metrics object must be present');
143
+ assert.ok(payload.metrics.tokens !== undefined, 'Tokens object must be present in metrics');
144
+ assert.ok(typeof payload.metrics.costUsd === 'number', 'costUsd must be a number in metrics');
145
+ });
146
+
147
+ test('Commands: /profile and /config execute cleanly in routeCommand', async () => {
148
+ const logs = [];
149
+ const fakeContext = {
150
+ sub: 'list',
151
+ extra: '',
152
+ parts: ['/profile', 'list'],
153
+ agent: null,
154
+ client: {
155
+ provider: 'lmstudio',
156
+ providerName: 'LM Studio (Local)',
157
+ model: 'google/gemma-4-e4b',
158
+ baseUrl: 'http://localhost:1234/v1'
159
+ }
160
+ };
161
+
162
+ const oldLog = console.log;
163
+ try {
164
+ console.log = (msg) => logs.push(msg);
165
+
166
+ await routeCommand('/profile', fakeContext);
167
+ assert.ok(logs.length > 0);
168
+
169
+ logs.length = 0;
170
+ await routeCommand('/profile', { ...fakeContext, sub: 'show', extra: 'fast', parts: ['/profile', 'show', 'fast'] });
171
+ assert.ok(logs.length > 0);
172
+
173
+ logs.length = 0;
174
+ await routeCommand('/config', { ...fakeContext, sub: '', extra: '', parts: ['/config'] });
175
+ assert.ok(logs.length > 0);
176
+ } finally {
177
+ console.log = oldLog;
178
+ }
179
+ });
@@ -0,0 +1,80 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ createTerminalImageGallery,
7
+ renderJpegFile,
8
+ supportsTerminalImages,
9
+ } from '../terminalImage.js';
10
+ import { getStringDisplayWidth, stripAnsi } from '../ui.js';
11
+
12
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
13
+ const imageDirectory = path.resolve(testDirectory, '../../../src/assets/images');
14
+ const squareImage = path.join(imageDirectory, '0040jbadly8icq3if7twwj60qy0qy0uc02.jpg');
15
+
16
+ test('renders a JPEG as bounded true-color quadrant rows', () => {
17
+ const lines = renderJpegFile(squareImage, { columns: 30, rows: 20, maxWidth: 20, maxRows: 6 });
18
+
19
+ assert.equal(lines.length, 6);
20
+ assert.match(lines[0], /\x1b\[38;2;\d+;\d+;\d+m/);
21
+ assert.match(lines[0], /\x1b\[48;2;\d+;\d+;\d+m/);
22
+ assert.match(stripAnsi(lines[0]), /^ [ ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█]+$/);
23
+ assert.ok(getStringDisplayWidth(lines[0]) <= 22);
24
+ assert.match(stripAnsi(lines.join('')), /[▘▝▖▗▚▞▛▜▙▟]/);
25
+ });
26
+
27
+ test('default gallery rendering uses most of a standard TUI viewport', () => {
28
+ const lines = renderJpegFile(squareImage, { columns: 80, rows: 24 });
29
+
30
+ assert.equal(lines.length, 17);
31
+ assert.equal(getStringDisplayWidth(lines[0]), 36);
32
+ assert.ok(new Set(lines).size > 12, 'photo detail should survive downsampling');
33
+ });
34
+
35
+ test('terminal image detection respects TTY, NO_COLOR, and explicit overrides', () => {
36
+ assert.equal(supportsTerminalImages({ env: {}, isTTY: true }), true);
37
+ assert.equal(supportsTerminalImages({ env: {}, isTTY: false }), false);
38
+ assert.equal(supportsTerminalImages({ env: { NO_COLOR: '1' }, isTTY: true }), false);
39
+ assert.equal(supportsTerminalImages({ env: { KIKU_TUI_IMAGES: 'on' }, isTTY: false }), true);
40
+ assert.equal(supportsTerminalImages({ env: { KIKU_TUI_IMAGES: 'off' }, isTTY: true }), false);
41
+ });
42
+
43
+ test('gallery renders on its cadence, supports manual display, and cycles assets', () => {
44
+ const gallery = createTerminalImageGallery({
45
+ directory: imageDirectory,
46
+ frequency: 3,
47
+ env: { KIKU_TUI_IMAGES: 'on' },
48
+ isTTY: false,
49
+ getColumns: () => 40,
50
+ getRows: () => 24,
51
+ });
52
+
53
+ assert.equal(gallery.enabled, true);
54
+ assert.equal(gallery.imageCount, 8);
55
+ assert.deepEqual(gallery.maybeRender(1), []);
56
+ assert.match(stripAnsi(gallery.maybeRender(3)[1]), /Gallery 1\/8/);
57
+ assert.match(stripAnsi(gallery.maybeRender(4, { force: true })[1]), /Gallery 2\/8/);
58
+ });
59
+
60
+ test('renderTwoImagesSideBySide and getWelcomeGalleryLines render 2 photos', async () => {
61
+ const { renderTwoImagesSideBySide, getWelcomeGalleryLines, discoverImages } = await import('../terminalImage.js');
62
+ const images = discoverImages(imageDirectory);
63
+ assert.ok(images.length >= 2);
64
+
65
+ const pair = renderTwoImagesSideBySide(images[0], images[1], { columns: 100, rows: 24, maxRows: 10 });
66
+ assert.ok(pair.length > 0);
67
+ assert.ok(pair.every(line => typeof line === 'string'));
68
+
69
+ const welcomeGallery = getWelcomeGalleryLines(2, {
70
+ directory: imageDirectory,
71
+ env: { KIKU_TUI_IMAGES: 'on' },
72
+ isTTY: false,
73
+ columns: 100,
74
+ rows: 30,
75
+ });
76
+
77
+ assert.ok(welcomeGallery.length > 0);
78
+ assert.match(stripAnsi(welcomeGallery[1]), /JU JINGYI • 鞠婧祎 GALLERY/);
79
+ });
80
+
@@ -0,0 +1,166 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ expandMarkdownLinks,
6
+ formatMarkdownLines,
7
+ formatSolidPanelLines,
8
+ isMarkdownTableSeparator,
9
+ parseMarkdownTableRow,
10
+ } from '../ui.js';
11
+
12
+ test('parses Markdown table rows without retaining outer pipes', () => {
13
+ assert.deepEqual(
14
+ parseMarkdownTableRow('| Brand | Role & Highlight | Media Link |'),
15
+ ['Brand', 'Role & Highlight', 'Media Link']
16
+ );
17
+ assert.deepEqual(parseMarkdownTableRow('Brand | Category'), ['Brand', 'Category']);
18
+ });
19
+
20
+ test('recognizes Markdown table alignment separators', () => {
21
+ assert.equal(isMarkdownTableSeparator([':---', '---:', ':---:']), true);
22
+ assert.equal(isMarkdownTableSeparator(['Brand', 'Category']), false);
23
+ });
24
+
25
+ test('expands Markdown links before terminal line wrapping', () => {
26
+ assert.equal(
27
+ expandMarkdownLinks('[Search Commercial](https://example.com/a-very-long-link)'),
28
+ 'Search Commercial: https://example.com/a-very-long-link'
29
+ );
30
+ });
31
+
32
+ test('formatMarkdownLines converts markdown headers, bold, and tables to formatted terminal rows', () => {
33
+ const sample = `### Evidence Report: Ju Jingyi Brand Endorsements
34
+
35
+ **Summary:**
36
+ Ju Jingyi has secured multiple ambassador roles.
37
+
38
+ | Brand Name | Category | Role/Title |
39
+ | :--- | :--- | :--- |
40
+ | **PRSR** | Luxury Eyewear | Global Ambassador |
41
+ | **Lafang** | Haircare | Ambassador |`;
42
+
43
+ const rows = formatMarkdownLines(sample, 80);
44
+ assert.ok(rows.length > 0);
45
+ assert.ok(rows.some(r => r.includes('Evidence Report')));
46
+ assert.ok(rows.some(r => r.includes('┌') && r.includes('┐')));
47
+ assert.ok(rows.some(r => r.includes('PRSR')));
48
+ assert.ok(rows.some(r => r.includes('└') && r.includes('┘')));
49
+ });
50
+
51
+ test('formatSolidPanelLines preserves ANSI-colored box table lines without destroying structure', () => {
52
+ const tableSample = `| Year | Award Title | Ceremony |
53
+ | :--- | :--- | :--- |
54
+ | 2024 | Popular Actress | Weibo TV |
55
+ | 2021 | Outstanding Actress | Tencent Video |`;
56
+
57
+ const tableRows = formatMarkdownLines(tableSample, 80);
58
+ const panelRows = formatSolidPanelLines({
59
+ title: 'Awards Panel',
60
+ lines: tableRows,
61
+ width: 80,
62
+ });
63
+
64
+ assert.ok(panelRows.length > 0);
65
+ assert.ok(panelRows.some(r => r.includes('┌') && r.includes('┐')));
66
+ assert.ok(panelRows.some(r => r.includes('Popular Actress')));
67
+ assert.ok(panelRows.some(r => r.includes('Outstanding Actress')));
68
+ assert.ok(panelRows.some(r => r.includes('└') && r.includes('┘')));
69
+ });
70
+
71
+ test('formatMarkdownLines handles markdown tables with URLs without column breakage', () => {
72
+ const tableWithUrls = `| Detail | Finding | Source URLs |
73
+ | :--- | :--- | :--- |
74
+ | SNH48 Debut | Joined SNH48 Team NII in November 2013 | [https://snh48.fandom.com/wiki/Ju_Jingyi](https://snh48.fandom.com/wiki/Ju_Jingyi) |
75
+ | Solo Debut | Established studio in December 2017 | [https://baike.baidu.com/item/Ju_Jingyi](https://baike.baidu.com/item/Ju_Jingyi) |`;
76
+
77
+ const rows = formatMarkdownLines(tableWithUrls, 120);
78
+ assert.ok(rows.length > 0);
79
+
80
+ assert.ok(rows.some(r => r.includes('┌') && r.includes('┐')));
81
+ assert.ok(rows.some(r => r.includes('SNH48 Debut')));
82
+ assert.ok(rows.some(r => r.includes('Solo Debut')));
83
+ assert.ok(rows.some(r => r.includes('└') && r.includes('┘')));
84
+ });
85
+
86
+ test('formatMarkdownLines handles markdown tables with short separators and unequal columns', () => {
87
+ const unevenTable = `| Title | Year | Category |
88
+ | -- | :-: | -- |
89
+ | In Blossom | 2024 | Drama |
90
+ | Everyday | 2016 |
91
+ | Rain Love | 2024 | Single | Extra Info |`;
92
+
93
+ const rows = formatMarkdownLines(unevenTable, 100);
94
+ assert.ok(rows.length > 0);
95
+ assert.ok(rows.some(r => r.includes('┌') && r.includes('┐')));
96
+ assert.ok(rows.some(r => r.includes('In Blossom')));
97
+ assert.ok(rows.some(r => r.includes('Everyday')));
98
+ assert.ok(rows.some(r => r.includes('Rain Love')));
99
+ assert.ok(rows.some(r => r.includes('└') && r.includes('┘')));
100
+ });
101
+
102
+ test('formatTerminalMarkdown formats headings h1-h5 and numbered list items', () => {
103
+ const markdownText = `#### Subheading 4
104
+ ##### Subheading 5
105
+ 1. First point
106
+ 2. Second point`;
107
+
108
+ const rows = formatMarkdownLines(markdownText, 80);
109
+ assert.ok(rows.some(r => r.includes('Subheading 4')));
110
+ assert.ok(rows.some(r => r.includes('Subheading 5')));
111
+ assert.ok(rows.some(r => r.includes('1.') && r.includes('First point')));
112
+ assert.ok(rows.some(r => r.includes('2.') && r.includes('Second point')));
113
+ });
114
+
115
+ test('formatMarkdownLines wraps long cell contents across multiple lines without truncating with ellipsis', () => {
116
+ const table = `| Detail | Finding | Source URL(s) | Uncertainty |
117
+ | :--- | :--- | :--- | :--- |
118
+ | SNH48 General Election Success | Elected No.4 (2014), No.2 (2015), No.1 (2016), No.1 (2017) | [Wikipedia](https://en.wikipedia.org/wiki/Ju_Jingyi) | Low |
119
+ | Solo Transition | Established personal studio on December 15, 2017 | [carrd.co](https://ju-jingyi.carrd.co/) | Low |`;
120
+
121
+ const rows = formatMarkdownLines(table, 90);
122
+ assert.ok(rows.length > 0);
123
+
124
+ const tableBorderRows = rows.filter(r => r.includes('│'));
125
+ assert.ok(tableBorderRows.length > 0);
126
+ assert.ok(!tableBorderRows.some(r => r.includes('…')));
127
+
128
+ assert.ok(rows.some(r => r.includes('Uncertainty')));
129
+
130
+ assert.ok(rows.some(r => r.includes('Elected No.4')));
131
+ assert.ok(rows.some(r => r.includes('(2017)')));
132
+ });
133
+
134
+ test('cleanTableCell and formatMarkdownLines handle <br> tags cleanly without raw HTML tags', () => {
135
+ const tableWithBr = `| Service | Links |
136
+ | :--- | :--- |
137
+ | Portals | [carrd.co](https://ju-jingyi.carrd.co/)<br>[Wikipedia](https://en.wikipedia.org/wiki/Ju_Jingyi) |`;
138
+
139
+ const rows = formatMarkdownLines(tableWithBr, 80);
140
+ assert.ok(rows.length > 0);
141
+ assert.ok(!rows.some(r => r.includes('<br')));
142
+ assert.ok(!rows.some(r => r.includes('<b…')));
143
+ assert.ok(rows.some(r => r.includes('carrd.co')));
144
+ assert.ok(rows.some(r => r.includes('Wikipedia')));
145
+ });
146
+
147
+ test('formatMarkdownLines does not corrupt bullet points with pipes into artificial tables', () => {
148
+ const markdownText = [
149
+ 'Sources:',
150
+ ' • DuckDuckGo - Ju Jingyi Drama Tops Charts: https://www.youtube.com/watch?v=lkFoSXBARkQ',
151
+ '* DuckDuckGo - Best Chinese Dramas of 2026 So Far | Updated Rankings: https://mijudrama.com/blog/best-chinese-dramas-2026-updated-rankings',
152
+ ' • DuckDuckGo - Ju Jing Yi - DramaWiki: https://wiki.d-addicts.com/Ju_Jing_Yi',
153
+ ].join('\n');
154
+
155
+ const rows = formatMarkdownLines(markdownText, 100);
156
+ assert.equal(rows.some(r => r.startsWith('|') && r.endsWith('|')), false, 'Bullet with pipe should not become a table');
157
+ assert.ok(rows.some(r => r.includes('Best Chinese Dramas of 2026 So Far | Updated Rankings')));
158
+ });
159
+
160
+ test('formatTerminalMarkdown divider lines do not exceed maxWidth', () => {
161
+ const rows = formatMarkdownLines('---\n***\n___', 80);
162
+ for (const r of rows) {
163
+ const plain = r.replace(/\x1b\[[0-9;]*m/g, '');
164
+ assert.ok(plain.length <= 80, `Divider line should not exceed maxWidth (got ${plain.length})`);
165
+ }
166
+ });