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,141 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { COMMAND_NAMES } from '../commandRegistry.js';
5
+ import { routeCommand } from '../commands.js';
6
+ import { AgentJobManager } from '../jobManager.js';
7
+ import { AgentMemory } from '../memory.js';
8
+ import { getStringDisplayWidth, getWelcomeHeroLines, stripAnsi } from '../ui.js';
9
+
10
+ const RECENT_DATE = new Date(Date.now() - 3 * 86_400_000).toISOString().slice(0, 10);
11
+
12
+ function executor(name, args = {}) {
13
+ const query = String(args.query || '').toLowerCase();
14
+ if (name === 'search_works') {
15
+ const works = [
16
+ { id: 'in-blossom', titleTh: 'บุปผารักอลวน', titleEn: 'In Blossom', year: args.year || 2024, role: 'Yang Caiwei', platform: 'Youku', episodes: 32 },
17
+ { id: 'beauty-of-resilience', titleTh: 'ตำนานเลือดฟีนิกซ์', titleEn: 'Beauty of Resilience', year: args.year || 2023, role: 'Wei Zhi', platform: 'iQIYI', episodes: 36 },
18
+ ].filter(item => !query || `${item.titleTh} ${item.titleEn}`.toLowerCase().includes(query));
19
+ return Promise.resolve({ works, count: works.length });
20
+ }
21
+ if (name === 'search_discography') return Promise.resolve({ songs: [{ id: 'rain-love', title: 'Rain Love (雨恋)', year: args.year || 2024, type: 'OST', duration: '3:45', youtubeUrl: 'https://youtube.com/watch?v=test' }], count: 1 });
22
+ if (name === 'get_character_roles') return Promise.resolve({ roles: [{ role: 'Yang Caiwei', roleCn: '杨采薇', workTitle: 'In Blossom', year: 2024 }], count: 1 });
23
+ if (name === 'get_brand_endorsements') return Promise.resolve({ brands: [{ brand: 'Versace', title: 'Global Brand Ambassador', category: 'Luxury', year: args.year || 2026 }], count: 1 });
24
+ if (name === 'get_awards') return Promise.resolve({ awards: [{ id: 'award-1', title: 'Weibo Actress of the Year', ceremony: 'Weibo Night', category: 'Acting', year: args.year || 2025 }], count: 1 });
25
+ if (name === 'get_gallery_photos') return Promise.resolve({ photos: [{ id: 'photo-1', title: 'Milan Fashion Week Portrait', category: 'fashion-magazine', year: args.year || 2025 }], count: 1 });
26
+ if (name === 'get_music_videos') return Promise.resolve({ videos: [{ id: 'stage-1', title: 'Live Stage Performance', category: 'Stage Performance', year: 2025, duration: '4:12', youtubeUrl: 'https://youtube.com/watch?v=stage' }], count: 1 });
27
+ if (name === 'web_search') return Promise.resolve({ results: [
28
+ { title: `Ju Jingyi official studio update ${RECENT_DATE}`, url: 'https://weibo.com/kiku-studio', snippet: 'A concise official update about the latest drama production.' },
29
+ { title: 'Ju Jingyi interview', url: 'https://example.com/interview', snippet: 'A recent interview covering music and acting.' },
30
+ ] });
31
+ return Promise.resolve({});
32
+ }
33
+
34
+ function createContext() {
35
+ const memory = new AgentMemory();
36
+ memory.save = async () => true;
37
+ memory.exportFavorites = async format => ({ filePath: `C:\\kiku-test\\favorites.${format === 'markdown' ? 'md' : 'json'}`, count: memory.getFavorites().length });
38
+ const jobs = new AgentJobManager();
39
+ const running = jobs.create('research', 'Inspect the newest Ju Jingyi drama announcement and collect trustworthy sources');
40
+ return {
41
+ context: {
42
+ executeTool: executor,
43
+ random: () => 0,
44
+ agent: {
45
+ memory,
46
+ getJobs: () => jobs.list(),
47
+ getJob: id => jobs.get(id),
48
+ cancelJob: id => jobs.cancel(id),
49
+ clearJobs: () => jobs.clear(),
50
+ },
51
+ },
52
+ memory,
53
+ jobs,
54
+ running,
55
+ };
56
+ }
57
+
58
+ async function captureAtWidth(width, run) {
59
+ const originalLog = console.log;
60
+ const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
61
+ const output = [];
62
+ Object.defineProperty(process.stdout, 'columns', { value: width, configurable: true });
63
+ console.log = (...args) => output.push(args.join(' '));
64
+ try {
65
+ const result = await run();
66
+ return { result, output: output.join('\n') };
67
+ } finally {
68
+ console.log = originalLog;
69
+ if (originalDescriptor) Object.defineProperty(process.stdout, 'columns', originalDescriptor);
70
+ else delete process.stdout.columns;
71
+ }
72
+ }
73
+
74
+ function assertHealthyTui(output, width) {
75
+ assert.doesNotMatch(output, /undefined|\[object Object\]|NaN/);
76
+ for (const line of output.split('\n')) {
77
+ assert.ok(getStringDisplayWidth(stripAnsi(line)) <= width, `TUI line exceeded ${width} columns: ${stripAnsi(line)}`);
78
+ }
79
+ }
80
+
81
+ test('every canonical command exposes its help route without executing side effects', async () => {
82
+ const seen = [];
83
+ for (const command of COMMAND_NAMES) {
84
+ const result = await routeCommand(`${command} help`, { printHelp: name => seen.push(name) });
85
+ assert.equal(result, 'HANDLED', command);
86
+ }
87
+ assert.deepEqual(seen, COMMAND_NAMES.map(command => command === '/help' ? 'help' : command));
88
+ });
89
+
90
+ for (const width of [80, 120]) {
91
+ test(`welcome TUI is bounded and complete at ${width} columns`, async () => {
92
+ const rendered = await captureAtWidth(width, async () => getWelcomeHeroLines());
93
+ const lines = rendered.result;
94
+ assert.match(stripAnsi(lines.join('\n')), /Ju Jingyi/);
95
+ assert.match(stripAnsi(lines.join('\n')), /Ctrl\+C/);
96
+ for (const line of lines) assert.ok(getStringDisplayWidth(stripAnsi(line)) <= width, stripAnsi(line));
97
+ if (width === 80) assert.ok(lines.length <= 14);
98
+ });
99
+ }
100
+
101
+ for (const width of [80, 120]) {
102
+ test(`new command TUI matrix is complete and bounded at ${width} columns`, async () => {
103
+ const { context, memory, running } = createContext();
104
+ const commands = [
105
+ ['/find "In Blossom" --type work --limit 4', /Catalog Search/],
106
+ ['/find Versace --type brand --year 2026', /Versace/],
107
+ ['/find test --type invalid', /COMMAND ERROR/],
108
+ ['/compare "In Blossom" -- "Beauty of Resilience" --type work', /Compare/],
109
+ ['/compare 2024 -- 2025', /2024/],
110
+ ['/compare missing separator', /Usage/],
111
+ ['/favorites list', /Favorites/],
112
+ ['/favorites add song "Rain Love"', /Favorite Added/],
113
+ ['/favorites list --type song', /Rain Love/],
114
+ ['/favorites export markdown', /Favorites Exported/],
115
+ ['/news drama --days 7', /News/],
116
+ ['/news drama --days 14 --official-only', /weibo\.com/],
117
+ ['/news --days 0', /COMMAND ERROR/],
118
+ ['/jobs list', new RegExp(running.id)],
119
+ [`/jobs show ${running.id}`, /Inspect the newest/],
120
+ [`/jobs cancel ${running.id}`, /cancellation requested/],
121
+ ['/jobs clear', /Cleared/],
122
+ ['/discover drama', /บุปผารักอลวน/],
123
+ ['/discover song --mood calm', /Rain Love/],
124
+ ['/discover stage --year 2025', /Live Stage/],
125
+ ['/discover photo', /Milan Fashion Week/],
126
+ ['/discover award', /COMMAND ERROR/],
127
+ ];
128
+
129
+ for (const [command, expected] of commands) {
130
+ const rendered = await captureAtWidth(width, () => routeCommand(command, context));
131
+ assert.equal(rendered.result, 'HANDLED', command);
132
+ assert.match(rendered.output, expected, command);
133
+ assertHealthyTui(rendered.output, width);
134
+ }
135
+
136
+ const favorite = memory.getFavorites()[0];
137
+ const removed = await captureAtWidth(width, () => routeCommand(`/favorites remove ${favorite.id}`, context));
138
+ assert.match(removed.output, /Favorite removed/);
139
+ assertHealthyTui(removed.output, width);
140
+ });
141
+ }
@@ -0,0 +1,113 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { extractYouTubeVideoId, parseSubtitleToText } from '../youtubeTranscript.js';
5
+ import { executeTool, toolHandlers, toolsSchema } from '../tools.js';
6
+ import { KikuAgent } from '../agentCore.js';
7
+
8
+ function bareAgent() {
9
+ const agent = Object.create(KikuAgent.prototype);
10
+ agent.mcpManager = null;
11
+ agent.allowedToolNames = null;
12
+ return agent;
13
+ }
14
+
15
+ test('get_current_datetime returns a coherent current date', async () => {
16
+ const result = await executeTool('get_current_datetime', {});
17
+
18
+ assert.equal(result.status, 'success');
19
+ assert.equal(result.year, new Date().getFullYear());
20
+ assert.ok(Number.isFinite(result.timestampMs));
21
+ assert.match(result.timezoneOffset, /^[+-]\d{2}:\d{2}$/);
22
+ assert.match(result.iso, /^\d{4}-\d{2}-\d{2}T/);
23
+ assert.ok(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].includes(result.weekday));
24
+ });
25
+
26
+ test('system prompt injects the live year instead of a hardcoded one', () => {
27
+ const prompt = bareAgent().buildSystemPrompt();
28
+ assert.ok(!prompt.includes('{{CURRENT_YEAR}}'));
29
+ assert.ok(prompt.includes(`THE CURRENT YEAR IS ${new Date().getFullYear()}`));
30
+ assert.ok(!prompt.includes('THE CURRENT YEAR IS 2026.'));
31
+ });
32
+
33
+ test('get_profile_and_fandom exposes previously hidden sections', async () => {
34
+ const talents = await toolHandlers.get_profile_and_fandom({ section: 'talents' });
35
+ assert.ok(Array.isArray(talents.talents));
36
+ assert.ok(talents.talents.length > 0);
37
+ assert.equal(talents.talents[0].title.length > 0, true);
38
+
39
+ const honors = await toolHandlers.get_profile_and_fandom({ section: 'honors' });
40
+ assert.ok(honors.honors.some(h => /Forbes/.test(h)));
41
+
42
+ const social = await toolHandlers.get_profile_and_fandom({ section: 'social' });
43
+ assert.ok(social.socialLinks.some(s => /^https?:\/\//.test(s.url)));
44
+
45
+ const all = await executeTool('get_profile_and_fandom', {});
46
+ assert.equal(all.status, 'success');
47
+ assert.ok(all.talents.length > 0);
48
+ assert.ok(all.stats.length > 0);
49
+ assert.ok(all.honors.length > 0);
50
+ assert.ok(all.socialLinks.length > 0);
51
+ });
52
+
53
+ test('get_gallery_photos includes the served image path', async () => {
54
+ const result = await executeTool('get_gallery_photos', { limit: 2 });
55
+ assert.equal(result.status, 'success');
56
+ assert.ok(result.photos.length > 0);
57
+ assert.match(result.photos[0].imageUrl, /^\/images\//);
58
+ });
59
+
60
+ test('extractYouTubeVideoId understands URLs and bare IDs', () => {
61
+ assert.equal(extractYouTubeVideoId('9AeErG03SZw'), '9AeErG03SZw');
62
+ assert.equal(extractYouTubeVideoId('https://www.youtube.com/watch?v=9AeErG03SZw'), '9AeErG03SZw');
63
+ assert.equal(extractYouTubeVideoId('https://youtu.be/OMLpNyp8T7Q?t=30'), 'OMLpNyp8T7Q');
64
+ assert.equal(extractYouTubeVideoId('https://www.youtube.com/shorts/abcd1234efg'), 'abcd1234efg');
65
+ assert.equal(extractYouTubeVideoId('not a youtube link'), null);
66
+ });
67
+
68
+ test('parseSubtitleToText flattens VTT with inline tags and dedupes rolling captions', () => {
69
+ const vtt = [
70
+ 'WEBVTT',
71
+ '',
72
+ '00:00:01.000 --> 00:00:03.000',
73
+ '<c.colorE5E5E5>Hello</c> world',
74
+ '',
75
+ '00:00:03.000 --> 00:00:05.000',
76
+ 'Hello world',
77
+ '',
78
+ 'NOTE This is a comment',
79
+ '',
80
+ '00:00:05.000 --> 00:00:07.000',
81
+ 'Second &amp; final line',
82
+ ].join('\n');
83
+
84
+ const text = parseSubtitleToText(vtt);
85
+ assert.equal(text, 'Hello world Second & final line');
86
+ });
87
+
88
+ test('parseSubtitleToText skips SRT sequence numbers', () => {
89
+ const srt = [
90
+ '1',
91
+ '00:00:01,000 --> 00:00:03,000',
92
+ 'First line',
93
+ '',
94
+ '2',
95
+ '00:00:03,000 --> 00:00:05,000',
96
+ '2024',
97
+ ].join('\n');
98
+
99
+ const text = parseSubtitleToText(srt);
100
+ assert.equal(text, 'First line 2024');
101
+ });
102
+
103
+ test('get_youtube_transcript requires a url or videoId', async () => {
104
+ const result = await executeTool('get_youtube_transcript', {});
105
+ assert.equal(result.status, 'error');
106
+ assert.match(result.message, /valid YouTube URL|videoId/i);
107
+ });
108
+
109
+ test('new tools are exposed through the schema and executable via the standard pipeline', () => {
110
+ const names = toolsSchema.map(t => t.function.name);
111
+ assert.ok(names.includes('get_current_datetime'));
112
+ assert.ok(names.includes('get_youtube_transcript'));
113
+ });
@@ -0,0 +1,178 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ extractArticleDate,
8
+ classifySource,
9
+ classifyTopics,
10
+ dedupeNewsItems,
11
+ rankNewsItems,
12
+ mergeNewsResults,
13
+ newsFingerprint,
14
+ } from '../newsIntelligence.js';
15
+ import { checkNewsWatch, loadWatchState, saveWatchState } from '../newsWatch.js';
16
+
17
+ const NOW = new Date('2026-09-12T12:00:00Z');
18
+
19
+ test('extractArticleDate parses ISO, Chinese, Thai BE, and English dates', () => {
20
+ const iso = extractArticleDate({ title: 'Ju Jingyi new drama 2026-09-01 announced', snippet: '' }, { now: NOW });
21
+ assert.equal(iso.source, 'iso');
22
+ assert.equal(iso.date.toISOString().slice(0, 10), '2026-09-01');
23
+
24
+ const zh = extractArticleDate({ title: '鞠婧祎 2026年9月10日 发新歌', snippet: '' }, { now: NOW });
25
+ assert.equal(zh.source, 'zh');
26
+ assert.equal(zh.date.toISOString().slice(0, 10), '2026-09-10');
27
+
28
+ const thBE = extractArticleDate({ title: 'จวีจิ้งอีปล่อยเพลงใหม่ 12 ก.ย. 2568', snippet: '' }, { now: NOW });
29
+ assert.equal(thBE.source, 'th');
30
+ assert.equal(thBE.date.toISOString().slice(0, 10), '2025-09-12', 'Buddhist Era 2568 must convert to CE 2025');
31
+
32
+ const thFull = extractArticleDate({ title: '', snippet: '12 กันยายน 2026 จวีจิ้งอีคว้ารางวัล' }, { now: NOW });
33
+ assert.equal(thFull.source, 'th');
34
+ assert.equal(thFull.date.toISOString().slice(0, 10), '2026-09-12');
35
+
36
+ const en1 = extractArticleDate({ title: 'Ju Jingyi appears at event Sep 12, 2026', snippet: '' }, { now: NOW });
37
+ assert.equal(en1.source, 'en');
38
+ assert.equal(en1.date.toISOString().slice(0, 10), '2026-09-12');
39
+
40
+ const en2 = extractArticleDate({ title: '', snippet: '12 September 2026 studio update' }, { now: NOW });
41
+ assert.equal(en2.date.toISOString().slice(0, 10), '2026-09-12');
42
+ });
43
+
44
+ test('extractArticleDate prefers RSS pubDate and supports relative dates', () => {
45
+ const rss = extractArticleDate({ publishedAt: '2026-09-08T06:30:00Z', title: 'x 2026-01-01' }, { now: NOW });
46
+ assert.equal(rss.source, 'rss');
47
+ assert.equal(rss.date.toISOString().slice(0, 10), '2026-09-08');
48
+
49
+ const enRel = extractArticleDate({ title: 'Ju Jingyi new single posted 3 hours ago', snippet: '' }, { now: NOW });
50
+ assert.equal(enRel.source, 'relative');
51
+ assert.equal(enRel.date.toISOString(), '2026-09-12T09:00:00.000Z');
52
+
53
+ const zhRel = extractArticleDate({ title: '鞠婧祎工作室 5小时前 发布', snippet: '' }, { now: NOW });
54
+ assert.equal(zhRel.date.toISOString(), '2026-09-12T07:00:00.000Z');
55
+
56
+ const thRel = extractArticleDate({ title: 'จวีจิ้งอี 2 วันที่แล้ว', snippet: '' }, { now: NOW });
57
+ assert.equal(thRel.date.toISOString(), '2026-09-10T12:00:00.000Z');
58
+
59
+ const none = extractArticleDate({ title: 'Ju Jingyi something happened', snippet: '' }, { now: NOW });
60
+ assert.equal(none.date, null);
61
+ assert.equal(none.source, null);
62
+ });
63
+
64
+ test('classifySource assigns trust tiers to official, press, and community hosts', () => {
65
+ assert.deepEqual(classifySource('https://weibo.com/u/123456'), { tier: 1, label: 'official' });
66
+ assert.deepEqual(classifySource('https://m.weibo.cn/status/abc'), { tier: 1, label: 'official' });
67
+ assert.deepEqual(classifySource('https://www.youtube.com/watch?v=x'), { tier: 1, label: 'official' });
68
+ assert.equal(classifySource('https://www.thairath.co.th/entertain/x').tier, 2);
69
+ assert.equal(classifySource('https://ent.sohu.com/a/1').tier, 2);
70
+ assert.deepEqual(classifySource('https://fanblog.example.com/post'), { tier: 3, label: 'community' });
71
+ });
72
+
73
+ test('classifyTopics tags music, drama, brand, award, and event coverage', () => {
74
+ assert.equal(classifyTopics({ title: 'Ju Jingyi new single MV released', snippet: '' }).primary, 'music');
75
+ assert.equal(classifyTopics({ title: 'จวีจิ้งอี รับบท leading role ซีรีส์ใหม่', snippet: '' }).primary, 'drama');
76
+ assert.equal(classifyTopics({ title: 'Ju Jingyi named brand ambassador', snippet: '' }).primary, 'brand');
77
+ assert.ok(classifyTopics({ title: 'Ju Jingyi wins award', snippet: '' }).tags.includes('award'));
78
+ assert.equal(classifyTopics({ title: 'Unrelated headline', snippet: '' }).primary, 'general');
79
+ });
80
+
81
+ test('dedupeNewsItems collapses near-duplicate stories and keeps distinct ones', () => {
82
+ const a = { title: 'Ju Jingyi releases new single 0.2s topping three regional charts', snippet: 'The single topped charts this week across regional platforms.', url: 'https://press-a.com/1' };
83
+ const b = { title: 'Ju Jingyi releases new single 0.2s topping three regional charts!', snippet: 'The single topped charts this week across regional platforms.', url: 'https://press-b.com/2' };
84
+ const c = { title: 'Ju Jingyi opening fanmeeting tour in Bangkok next month', snippet: 'Tickets go on sale for the Bangkok fanmeeting tour next month.', url: 'https://press-c.com/3' };
85
+
86
+ const kept = dedupeNewsItems([a, b, c]);
87
+ assert.equal(kept.length, 2);
88
+ assert.equal(kept[0].url, 'https://press-a.com/1');
89
+ assert.equal(kept[1].url, 'https://press-c.com/3');
90
+
91
+ const urlDupes = dedupeNewsItems([
92
+ { ...a, url: 'https://x.com/same?utm=1' },
93
+ { ...a, url: 'https://x.com/same?utm=2' },
94
+ ]);
95
+ assert.equal(urlDupes.length, 1, 'URL normalization must drop same-article copies');
96
+ });
97
+
98
+ test('rankNewsItems sorts by recency, filters by cutoff, and applies officialOnly', () => {
99
+ const fresh = { title: 'fresh official weibo post', snippet: '', url: 'https://weibo.com/u/1/1', publishedAt: '2026-09-12T10:00:00Z' };
100
+ const monthOld = { title: 'older press story', snippet: '', url: 'https://www.thairath.co.th/e/2', publishedAt: '2026-08-10T00:00:00Z' };
101
+ const undated = { title: 'undated fan blog post', snippet: '', url: 'https://blog.example.com/3' };
102
+
103
+ const ranked = rankNewsItems([monthOld, undated, fresh], { now: NOW });
104
+ assert.equal(ranked[0].title, fresh.title, 'fresh items must outrank older and undated ones');
105
+
106
+ const cutoffFiltered = rankNewsItems([monthOld, fresh], { now: NOW, cutoff: new Date('2026-09-01T00:00:00Z') });
107
+ assert.equal(cutoffFiltered.length, 1);
108
+ assert.equal(cutoffFiltered[0].title, fresh.title);
109
+
110
+ const official = rankNewsItems([fresh, monthOld], { now: NOW, officialOnly: true });
111
+ assert.equal(official.length, 1);
112
+ assert.equal(official[0].tier, 1);
113
+ });
114
+
115
+ test('mergeNewsResults fuses RSS and web groups with dedup and budget', () => {
116
+ const rss = [
117
+ { title: 'Studio announces comeback single', snippet: 'Official studio announcement of the comeback single this month.', url: 'https://press-a.com/comeback', publishedAt: '2026-09-11T00:00:00Z' },
118
+ { title: 'Drama filming wraps', snippet: 'The drama filming has wrapped after three months of production.', url: 'https://press-b.com/drama', publishedAt: '2026-09-05T00:00:00Z' },
119
+ ];
120
+ const web = [
121
+ { title: 'Studio announces comeback single', snippet: 'Official studio announcement of the comeback single this month.', url: 'https://aggregator.example/comeback' },
122
+ { title: 'Brand ambassador campaign launches', snippet: 'New brand ambassador campaign launches with a nationwide rollout.', url: 'https://press-c.com/brand', publishedAt: '2026-09-09T00:00:00Z' },
123
+ ];
124
+
125
+ const { items, totalUnique } = mergeNewsResults({
126
+ groups: [{ name: 'news-rss', results: rss }, { name: 'web', results: web }],
127
+ now: NOW,
128
+ budget: 10,
129
+ });
130
+ assert.equal(totalUnique, 3, 'same story from two groups must collapse to one');
131
+ assert.equal(items[0].title, 'Studio announces comeback single', 'freshest official story must rank first');
132
+ assert.ok(items[0].via, 'merge must record which group each item came from');
133
+ });
134
+
135
+ function tmpStatePath() {
136
+ return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'kiku-news-')), 'watch-state.json');
137
+ }
138
+
139
+ test('checkNewsWatch detects first-run items, zero delta on repeat, and fresh items on change', async () => {
140
+ const statePath = tmpStatePath();
141
+ const fetcher = () => Promise.resolve([
142
+ { title: 'Ju Jingyi comeback stage confirmed for this weekend', snippet: 'Comeback stage confirmed with a special performance lineup.', url: 'https://press-a.com/1', publishedAt: '2026-09-11T00:00:00Z' },
143
+ ]);
144
+
145
+ const first = await checkNewsWatch({ topic: 'comeback', days: 7, fetcher, statePath, now: NOW });
146
+ assert.equal(first.fresh.length, 1);
147
+
148
+ const second = await checkNewsWatch({ topic: 'comeback', days: 7, fetcher, statePath, now: new Date(NOW.getTime() + 60000) });
149
+ assert.equal(second.fresh.length, 0, 'unchanged stories must not re-appear as fresh');
150
+
151
+ const changedFetcher = () => Promise.resolve([
152
+ { title: 'Ju Jingyi comeback stage confirmed for this weekend', snippet: 'Comeback stage confirmed with a special performance lineup.', url: 'https://press-a.com/1', publishedAt: '2026-09-11T00:00:00Z' },
153
+ { title: 'Ju Jingyi surprise digital single drops at midnight', snippet: 'A surprise digital single drops at midnight with an accompanying music video.', url: 'https://press-a.com/2', publishedAt: '2026-09-12T09:00:00Z' },
154
+ ]);
155
+ const third = await checkNewsWatch({ topic: 'comeback', days: 7, fetcher: changedFetcher, statePath, now: new Date(NOW.getTime() + 120000) });
156
+ assert.equal(third.fresh.length, 1, 'only the genuinely new story counts as fresh');
157
+ assert.equal(third.fresh[0].url, 'https://press-a.com/2');
158
+
159
+ const persisted = loadWatchState(statePath);
160
+ assert.ok(persisted.watches['comeback'].lastHashes.length >= 1);
161
+ });
162
+
163
+ test('watch state persists snapshots across processes', () => {
164
+ const statePath = tmpStatePath();
165
+ const state = loadWatchState(statePath);
166
+ state.watches['music'] = { lastHashes: ['abc'], lastCheckedAt: '2026-09-12T00:00:00.000Z', lastCount: 3 };
167
+ saveWatchState(state, statePath);
168
+
169
+ const reloaded = loadWatchState(statePath);
170
+ assert.equal(reloaded.watches['music'].lastHashes[0], 'abc');
171
+ assert.equal(reloaded.watches['music'].lastCount, 3);
172
+ });
173
+
174
+ test('newsFingerprint is stable for identical items', () => {
175
+ const item = { title: 'identical headline for fingerprint check', snippet: 'same body text' };
176
+ assert.equal(newsFingerprint(item), newsFingerprint({ ...item }));
177
+ assert.notEqual(newsFingerprint(item), newsFingerprint({ title: 'different headline entirely', snippet: 'other body' }));
178
+ });
@@ -0,0 +1,101 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DynamicToolRouter, TOOL_CLUSTERS } from '../toolRouter.js';
4
+ import { ContextCompactor, estimateTokenCount } from '../contextCompactor.js';
5
+ import { AgentMemory } from '../memory.js';
6
+ import { getUnifiedProfile, getUnifiedWorks, getUnifiedDiscography } from '../../mcp/dataAdapter.js';
7
+ import { eventBus, Events } from '../eventBus.js';
8
+
9
+ test('DynamicToolRouter filters tools based on prompt intent', () => {
10
+ const router = new DynamicToolRouter();
11
+
12
+ const mediaTools = router.routeToolsForQuery('เปิดเพลงของจวีจิ้งอีหน่อย');
13
+ assert.ok(mediaTools.some(t => t.function.name === 'play_music'));
14
+ assert.ok(mediaTools.some(t => t.function.name === 'search_discography'));
15
+ assert.ok(mediaTools.length < router.allTools.length);
16
+
17
+ const dramaTools = router.routeToolsForQuery('Ju Jingyi In Blossom drama synopsis');
18
+ assert.ok(dramaTools.some(t => t.function.name === 'search_works'));
19
+ assert.ok(dramaTools.some(t => t.function.name === 'get_work_detail'));
20
+
21
+ const fallbackTools = router.routeToolsForQuery('สวัสดีครับ');
22
+ assert.ok(fallbackTools.some(t => t.function.name === 'global_search'));
23
+ assert.ok(fallbackTools.length <= 10);
24
+ });
25
+
26
+ test('ContextCompactor calculates tokens and summarizes history without losing system message', () => {
27
+ const compactor = new ContextCompactor({ maxTokens: 50, keepRecentMessages: 2 });
28
+ const messages = [
29
+ { role: 'system', content: 'You are Kiku AI.' },
30
+ { role: 'user', content: 'Who is Ju Jingyi?' },
31
+ { role: 'assistant', content: 'Ju Jingyi is a singer and actress.' },
32
+ { role: 'user', content: 'Tell me about her songs.' },
33
+ { role: 'assistant', content: 'She has many solo singles.' },
34
+ { role: 'user', content: 'What is her latest drama in 2026?' },
35
+ ];
36
+
37
+ const tokens = estimateTokenCount(messages);
38
+ assert.ok(tokens > 0);
39
+
40
+ const compacted = compactor.compact(messages, { maxTokens: 20, keepRecentMessages: 2 });
41
+ assert.equal(compacted[0].role, 'system');
42
+ assert.equal(compacted[0].content, 'You are Kiku AI.');
43
+ assert.match(compacted[compacted.length - 1].content, /Previous Session Context Summary/);
44
+ assert.match(compacted[compacted.length - 1].content, /What is her latest drama in 2026\?/);
45
+ assert.equal(compacted.filter(message => message.role === 'system').length, 1);
46
+ assert.ok(compacted.some(m => typeof m.content === 'string' && m.content.includes('[Previous Session Context Summary')));
47
+ });
48
+
49
+ test('AgentMemory scores fact relevance and applies recency weighting', () => {
50
+ const memory = new AgentMemory();
51
+ const f1 = memory.addFact('Ju Jingyi won Top Female Singer in 2026', {
52
+ category: 'awards',
53
+ tags: ['singer', 'award', '2026'],
54
+ confidence: 'high'
55
+ });
56
+ const f2 = memory.addFact('Ju Jingyi starred in Legend of White Snake', {
57
+ category: 'drama',
58
+ tags: ['drama', 'white snake'],
59
+ confidence: 'high'
60
+ });
61
+
62
+ const relevant = memory.getRelevantFacts('awards 2026', 2);
63
+ assert.ok(relevant.length > 0);
64
+ assert.equal(relevant[0].content, f1.content);
65
+
66
+ const context = memory.buildMemoryContext('white snake drama');
67
+ assert.ok(context.includes('Legend of White Snake'));
68
+ });
69
+
70
+ test('SSOT DataAdapter provides consistent and valid records for MCP', () => {
71
+ const profile = getUnifiedProfile();
72
+ assert.equal(profile.nameCn, '鞠婧祎');
73
+ assert.ok(profile.fandomName.includes('Miju'));
74
+
75
+ const works = getUnifiedWorks();
76
+ assert.ok(works.length > 10);
77
+ assert.ok(works.some(w => w.year >= 2024));
78
+
79
+ const songs = getUnifiedDiscography();
80
+ assert.ok(songs.length > 20);
81
+ });
82
+
83
+ test('EventBus emits AGENT_PLAN lifecycle events for TUI Task Planning', () => {
84
+ const emitted = [];
85
+ const unsub1 = eventBus.on(Events.AGENT_PLAN_START, (d) => emitted.push({ type: 'start', data: d }));
86
+ const unsub2 = eventBus.on(Events.AGENT_PLAN_STEP_UPDATE, (d) => emitted.push({ type: 'update', data: d }));
87
+ const unsub3 = eventBus.on(Events.AGENT_PLAN_COMPLETE, (d) => emitted.push({ type: 'complete', data: d }));
88
+
89
+ eventBus.emit(Events.AGENT_PLAN_START, { planId: 'p1', steps: [{ id: 's1', state: 'pending' }] });
90
+ eventBus.emit(Events.AGENT_PLAN_STEP_UPDATE, { planId: 'p1', stepId: 's1', state: 'running' });
91
+ eventBus.emit(Events.AGENT_PLAN_COMPLETE, { planId: 'p1' });
92
+
93
+ assert.equal(emitted.length, 3);
94
+ assert.equal(emitted[0].type, 'start');
95
+ assert.equal(emitted[1].data.state, 'running');
96
+ assert.equal(emitted[2].type, 'complete');
97
+
98
+ unsub1();
99
+ unsub2();
100
+ unsub3();
101
+ });