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
@@ -16,11 +16,29 @@ import {
16
16
  } from './webSearch.js';
17
17
  import { readFile, readdir, stat, realpath } from 'node:fs/promises';
18
18
  import { join, relative, extname, resolve } from 'node:path';
19
- import { audioPlayer } from './audioPlayer.js';
19
+ import { audioPlayer, resolveCatalogSong } from './audioPlayer.js';
20
20
  import { fetchYoutubeTranscript } from './youtubeTranscript.js';
21
21
  import { sanitizeToolArgs, sanitizeUrl } from './sanitizer.js';
22
22
  import { getGpuMetrics } from './gpuMetrics.js';
23
23
  import { levenshteinDistance, levenshteinSimilarity, levenshteinScore, CATEGORY_ALIASES } from './catalogEntityResolver.js';
24
+ import { USER_ENV_PATH } from './envManager.js';
25
+ import { planSearchRequest } from './searchQueryPlanner.js';
26
+ import {
27
+ calculateHaversineDistance,
28
+ estimateWalkingTime,
29
+ estimateDrivingTime,
30
+ formatDistance,
31
+ findNearestLandmark,
32
+ buildOsmMapTag,
33
+ buildOsmUrl,
34
+ reverseGeocodeOsm,
35
+ searchLocationOsm,
36
+ cleanLocationQuery,
37
+ LANDMARK_REGISTRY,
38
+ detectIpLocation,
39
+ fetchRealtimeWeather,
40
+ scoreLocationConfidence
41
+ } from './locationService.js';
24
42
 
25
43
  export function buildYouTubeSearchUrl(query) {
26
44
  return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
@@ -50,11 +68,11 @@ export function formatCatalogTitle(primary, secondary) {
50
68
  }
51
69
 
52
70
  export const CATALOG_STOP_WORDS = new Set([
53
- 'the', 'from', 'what', 'are', 'is', 'about', 'drama', 'series', 'ost', 'song', 'songs', 'soundtrack', 'music', 'single', 'album', 'stage',
71
+ 'the', 'of', 'from', 'what', 'are', 'is', 'about', 'drama', 'series', 'ost', 'song', 'songs', 'soundtrack', 'music', 'single', 'album', 'stage',
54
72
  'brand', 'brands', 'ambassador', 'endorsement', 'commercial', 'role', 'roles', 'character', 'characters', 'award', 'awards', 'category', 'video', 'mv', 'clip',
55
73
  'ju', 'jingyi', 'jujingyi', 'kiku', 'kikuuu', 'จวีจิ้งอี', 'จวี', 'จิ้งอี', 'เสี่ยวจวี', 'เสี่ยว', '鞠婧祎', '小鞠', '鞠',
56
74
  'เพลง', 'เพลงของ', 'ซีรีส์', 'ซีรีส์ของ', 'ละคร', 'เรื่อง', 'ผลงาน', 'อัลบั้ม', 'คอนเสิร์ต', 'บทบาท', 'ตัวละคร', 'แบรนด์', 'รางวัล', 'ผลงานล่าสุด', 'พรีเซนเตอร์',
57
- 'ค้นหา', 'หา', 'ดู', 'เปิด', 'ฟัง', 'โปรโมท', 'โปรโมต', 'การโปรโมท', 'การโปรโมต', 'โฆษณา', 'แคมเปญ', 'ข้อมูล', 'รายละเอียด', 'บน', 'ใน', 'ที่', 'กับ', 'ช่วย', 'อยาก', 'ให้', 'หน่อย', 'คือ', 'อะไร', 'บ้าง', 'ไหม', 'มั้ย', 'มี',
75
+ 'ค้นหา', 'หา', 'ดู', 'เปิด', 'ฟัง', 'โปรโมท', 'โปรโมต', 'การโปรโมท', 'การโปรโมต', 'โฆษณา', 'แคมเปญ', 'ข้อมูล', 'รายละเอียด', 'บน', 'ใน', 'ที่', 'กับ', 'ช่วย', 'อยาก', 'ให้', 'หน่อย', 'คือ', 'อะไร', 'บ้าง', 'ไหม', 'มั้ย', 'มี', 'แนว', 'สไตล์', 'ประเภท', 'เกี่ยวกับ', 'เรื่องราว', 'แสดง', 'แสดงเป็น', 'รับบท', 'บท',
58
76
  'search', 'find', 'show', 'look', 'promo', 'promote', 'promoted', 'campaign', 'for', 'with', 'tell', 'me', 'please', 'can', 'you', 'info', 'details',
59
77
  '搜索', '查找', '看看', '播放', '在', '上的', '关于', '有什么', '吗', '呢',
60
78
  'bilibili', 'b站', 'youtube', 'weibo', 'douyin', 'spotify', 'qqmusic', 'iqiyi', 'youku', 'tencent'
@@ -79,10 +97,21 @@ export function createCatalogMatcher(query) {
79
97
  const raw = String(query).toLowerCase().trim();
80
98
  if (!raw) return () => true;
81
99
 
82
- const allTokens = raw.split(/[^\p{L}\p{M}\p{N}]+/u).filter(t => t.length >= 2);
83
100
  const deDated = raw.replace(/ใหม่ล่าสุด|ล่าสุด|最新|latest|newest|recent/gi, ' ');
101
+ let cleaned = deDated;
102
+ for (const sw of CATALOG_STOP_WORDS) {
103
+ if (sw.length >= 2) {
104
+ if (/^[a-z0-9]+$/i.test(sw)) {
105
+ cleaned = cleaned.replace(new RegExp('\\b' + sw + '\\b', 'gi'), ' ');
106
+ } else if (cleaned.includes(sw)) {
107
+ cleaned = cleaned.split(sw).join(' ');
108
+ }
109
+ }
110
+ }
111
+
84
112
  const datedTokens = deDated.split(/[^\p{L}\p{M}\p{N}]+/u).filter(t => t.length >= 2);
85
- const coreTokens = datedTokens.filter(t => !CATALOG_STOP_WORDS.has(t));
113
+ const strippedTokens = cleaned.split(/[^\p{L}\p{M}\p{N}]+/u).filter(t => t.length >= 2);
114
+ const coreTokens = (strippedTokens.length > 0 ? strippedTokens : datedTokens).filter(t => !CATALOG_STOP_WORDS.has(t));
86
115
 
87
116
  if (coreTokens.length === 0) {
88
117
  const broadMatcher = () => true;
@@ -188,6 +217,7 @@ const WEB_CACHE_TOOLS = new Set([
188
217
  'search_chinese_web',
189
218
  'search_bilibili',
190
219
  'search_web_images',
220
+ 'search_location_map',
191
221
  ]);
192
222
 
193
223
 
@@ -293,6 +323,81 @@ export const toolsSchema = [
293
323
  }
294
324
  }
295
325
  },
326
+ {
327
+ type: "function",
328
+ function: {
329
+ name: "get_user_location",
330
+ description: "Resolve current user's local coordinates and nearby landmark using IP or client GPS. Use this ONLY when the user asks about their own current location (e.g. 'แถวนี้', 'ใกล้ฉัน', 'here'). Do NOT use this if the user specified a destination name (use search_location_map or get_weather instead).",
331
+ parameters: {
332
+ type: "object",
333
+ properties: {
334
+ reason: {
335
+ type: "string",
336
+ description: "Optional purpose or reason for requesting location (e.g. 'weather_forecast', 'current_weather', 'nearby_services', 'local_time')."
337
+ },
338
+ query: {
339
+ type: "string",
340
+ description: "Optional place name, district, or landmark to search or resolve coordinates for."
341
+ },
342
+ latitude: {
343
+ type: "number",
344
+ description: "Optional latitude coordinate to reverse geocode and match nearest landmarks."
345
+ },
346
+ longitude: {
347
+ type: "number",
348
+ description: "Optional longitude coordinate to reverse geocode and match nearest landmarks."
349
+ }
350
+ },
351
+ required: []
352
+ }
353
+ }
354
+ },
355
+ {
356
+ type: "function",
357
+ function: {
358
+ name: "search_location_map",
359
+ description: "Search for geographic coordinates, formatted address, real-time weather, and interactive map tags [MAP_OSM:lat,lon,Name] of any named location, landmark, venue, concert hall, filming studio, or address. Use this when the user asks for maps, navigation, directions, or location information of places.",
360
+ parameters: {
361
+ type: "object",
362
+ properties: {
363
+ query: {
364
+ type: "string",
365
+ description: "The name of the location, venue, landmark, or address to search for (e.g. 'CentralWorld', 'Siam Paragon', '横店影视城', 'วัดพระแก้ว', 'ประตูท่าแพ')."
366
+ },
367
+ limit: {
368
+ type: "number",
369
+ description: "Maximum number of search results to return (default: 5, max: 10)."
370
+ }
371
+ },
372
+ required: ["query"]
373
+ }
374
+ }
375
+ },
376
+ {
377
+ type: "function",
378
+ function: {
379
+ name: "get_weather",
380
+ description: "Get real-time weather forecast, temperature, humidity, precipitation, wind speed, and conditions for any named city, landmark, or current location. Call this whenever answering questions about weather, rain, temperature, or climate. If the user specifies a place (e.g. 'CentralWorld', 'เซ็นทรัลเวิลด์', 'Chiang Mai'), pass it in 'location'.",
381
+ parameters: {
382
+ type: "object",
383
+ properties: {
384
+ location: {
385
+ type: "string",
386
+ description: "City, province, district, or landmark name (e.g. 'เซ็นทรัลเวิลด์', 'CentralWorld', 'กรุงเทพ', 'เชียงใหม่', 'Bangkok', 'Tokyo', 'Shanghai'). If omitted, uses current user location."
387
+ },
388
+ latitude: {
389
+ type: "number",
390
+ description: "Optional latitude coordinate."
391
+ },
392
+ longitude: {
393
+ type: "number",
394
+ description: "Optional longitude coordinate."
395
+ }
396
+ },
397
+ required: []
398
+ }
399
+ }
400
+ },
296
401
  {
297
402
  type: "function",
298
403
  function: {
@@ -559,9 +664,12 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
559
664
  parameters: {
560
665
  type: "object",
561
666
  properties: {
562
- query: { type: "string", description: "Search query keyword" }
667
+ keywords: { type: "string", description: "Canonical search keywords with conversational filler removed" },
668
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"], description: "Preferred language for search results" },
669
+ domain_filter: { type: "array", items: { type: "string" }, description: "Optional hostnames to prioritize or restrict, e.g. ['weibo.com', 'bilibili.com']" },
670
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" }
563
671
  },
564
- required: ["query"]
672
+ required: []
565
673
  }
566
674
  }
567
675
  },
@@ -569,7 +677,7 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
569
677
  type: "function",
570
678
  function: {
571
679
  name: "fetch_webpage",
572
- description: "Fetch and read the text content of a specific public web page URL. Returns extracted text — read the content field carefully and extract specific facts (names, dates, titles, descriptions) for your answer. NOTE: This cannot read SPA/React sites — use fetch_api for jujingyi-kikuuu.vercel.app.",
680
+ description: "Fetch and read the text content of a specific public web page URL. Returns extracted text — read the content field carefully and extract specific facts (names, dates, titles, descriptions) for your answer. NOTE: This cannot read SPA/React sites — use fetch_api for jujingyi-kikuuu.vercel.app. Login-walled or JavaScript-heavy social pages (Weibo, Douyin, Xiaohongshu) may return no readable text; when that happens, retry the mobile-mirror URL and then search_chinese_web / search_news on the topic instead of giving up.",
573
681
  parameters: {
574
682
  type: "object",
575
683
  properties: {
@@ -583,13 +691,13 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
583
691
  type: "function",
584
692
  function: {
585
693
  name: "fetch_api",
586
- description: "Query the Ju Jingyi Official REST API (jujingyi-kikuuu.vercel.app/api/v1) for structured JSON data. Use this instead of fetch_webpage when the user shares a link to the platform site, or when you need live/verified data from the official API.",
694
+ description: "Query the Ju Jingyi Official REST API (jujingyi-kikuuu.vercel.app/api/v1) for structured JSON data. Use this instead of fetch_webpage when the user shares a link to the platform site, or when you need live/verified data from the official API. For third-party or general external APIs, use fetch_json instead.",
587
695
  parameters: {
588
696
  type: "object",
589
697
  properties: {
590
- endpoint: { type: "string", description: "API endpoint path (e.g. 'profile', 'works', 'music', 'commercial', 'milestones', 'honors', 'social', 'search', 'stats', 'quotes', 'fandom', 'character-roles', 'gallery')" },
591
- query: { type: "string", description: "Optional search/filter query parameter" },
592
- baseUrl: { type: "string", description: "Optional override base URL (defaults to https://jujingyi-kikuuu.vercel.app/api/v1)" }
698
+ endpoint: { type: "string", description: "API endpoint path under /api/v1/ (e.g. 'profile', 'works', 'discography', 'mv', 'brands', 'timeline', 'awards', 'gallery', 'social', 'character-roles', 'fandom', 'quotes', 'stats', 'search'). Automatic aliases ('music' -> 'discography', 'commercial' -> 'brands', 'milestones' -> 'timeline', 'honors' -> 'awards') are supported." },
699
+ query: { type: "string", description: "Optional search/filter query parameter ('q')" },
700
+ baseUrl: { type: "string", description: "Optional override base URL (defaults to https://jujingyi-kikuuu.vercel.app/api/v1, or http://localhost:3000/api/v1 in local development)" }
593
701
  },
594
702
  required: ["endpoint"]
595
703
  }
@@ -682,10 +790,13 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
682
790
  parameters: {
683
791
  type: "object",
684
792
  properties: {
685
- query: { type: "string", description: "News search query, e.g. '鞠婧祎 最新' or 'Ju Jingyi news'" },
793
+ keywords: { type: "string", description: "Canonical news keywords, e.g. '鞠婧祎 最新'" },
794
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"] },
795
+ domain_filter: { type: "array", items: { type: "string" }, description: "Optional news or official-site hostnames" },
796
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
686
797
  maxResults: { type: "number", description: "Max results (default 8, max 15)" }
687
798
  },
688
- required: ["query"]
799
+ required: []
689
800
  }
690
801
  }
691
802
  },
@@ -712,11 +823,14 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
712
823
  parameters: {
713
824
  type: "object",
714
825
  properties: {
715
- term: { type: "string", description: "Encyclopedia term to look up, e.g. '鞠婧祎', 'Ju Jingyi', 'SNH48'" },
826
+ term: { type: "string", description: "Deprecated compatibility alias for keywords" },
827
+ keywords: { type: "string", description: "Canonical lookup term, e.g. '宋昕冉 SNH48'" },
828
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
716
829
  language: { type: "string", enum: ["zh", "th", "en"], description: "Optional: force language. Defaults to auto-detect from the term." },
830
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"], description: "Preferred language for results when language is unset" },
717
831
  maxEntries: { type: "number", description: "Max encyclopedia entries to return (default 2, max 3)" }
718
832
  },
719
- required: ["term"]
833
+ required: []
720
834
  }
721
835
  }
722
836
  },
@@ -728,10 +842,12 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
728
842
  parameters: {
729
843
  type: "object",
730
844
  properties: {
731
- term: { type: "string", description: "Song/album/artist search term, e.g. 'Ju Jingyi' or '叹云兮'" },
845
+ term: { type: "string", description: "Deprecated compatibility alias for keywords" },
846
+ keywords: { type: "string", description: "Canonical song/album/artist search term, e.g. 'Ju Jingyi 叹云兮'" },
847
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
732
848
  maxResults: { type: "number", description: "Max results (default 8, max 20)" }
733
849
  },
734
- required: ["term"]
850
+ required: []
735
851
  }
736
852
  }
737
853
  },
@@ -739,15 +855,18 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
739
855
  type: "function",
740
856
  function: {
741
857
  name: "search_chinese_web",
742
- description: "Search Chinese websites (Baidu first, then site-filtered DuckDuckGo across sina/sohu/weibo/bilibili/iqiyi/mgtv etc). Use for Chinese entertainment news, Weibo topics, and anything on Chinese portals. Requires KIKU_ENABLE_CN_WEB_TOOLS=true.",
858
+ description: "Search Chinese websites (Baidu first, then site-filtered DuckDuckGo across sina/sohu/weibo/bilibili/iqiyi/mgtv etc). Use for Chinese entertainment news, Weibo topics, and anything on Chinese portals. Enabled by default; set KIKU_ENABLE_CN_WEB_TOOLS=false to disable.",
743
859
  parameters: {
744
860
  type: "object",
745
861
  properties: {
746
- query: { type: "string", description: "Chinese search query, e.g. '鞠婧祎 微博'" },
862
+ keywords: { type: "string", description: "Canonical Chinese search keywords, e.g. '鞠婧祎 微博'" },
863
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"] },
864
+ domain_filter: { type: "array", items: { type: "string" }, description: "Optional Chinese portal hostnames" },
865
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
747
866
  maxResults: { type: "number", description: "Max results (default 8, max 15)" },
748
867
  site: { type: "string", description: "Optional domain hint to restrict fallback search, e.g. 'sina.com.cn'" }
749
868
  },
750
- required: ["query"]
869
+ required: []
751
870
  }
752
871
  }
753
872
  },
@@ -755,14 +874,17 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
755
874
  type: "function",
756
875
  function: {
757
876
  name: "search_bilibili",
758
- description: "Search Bilibili videos via its public API (stage performances, MVs, fancams, interviews). Returns title, author, duration, play count, publish date and video URL. Requires KIKU_ENABLE_CN_WEB_TOOLS=true.",
877
+ description: "Search Bilibili videos via its public API (stage performances, MVs, fancams, interviews). Returns title, author, duration, play count, publish date and video URL. Enabled by default; set KIKU_ENABLE_CN_WEB_TOOLS=false to disable.",
759
878
  parameters: {
760
879
  type: "object",
761
880
  properties: {
762
- keyword: { type: "string", description: "Bilibili search keyword, e.g. '鞠婧祎 舞台'" },
881
+ keyword: { type: "string", description: "Deprecated compatibility alias for keywords" },
882
+ keywords: { type: "string", description: "Canonical Bilibili search keywords, e.g. '宋昕冉 SNH48 舞台'" },
883
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
884
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"] },
763
885
  maxResults: { type: "number", description: "Max results (default 8, max 20)" }
764
886
  },
765
- required: ["keyword"]
887
+ required: []
766
888
  }
767
889
  }
768
890
  },
@@ -770,14 +892,17 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
770
892
  type: "function",
771
893
  function: {
772
894
  name: "search_web_images",
773
- description: "Search the web (Baidu Images & Chinese entertainment media portals) for photos, on-set stills (路透), red carpet appearances, and press images of Ju Jingyi. Returns direct image URLs, thumbnails, dimensions, and source article links. Requires KIKU_ENABLE_CN_WEB_TOOLS=true.",
895
+ description: "Search the web (Baidu Images & Chinese entertainment media portals) for photos, on-set stills (路透), red carpet appearances, and press images of Ju Jingyi. The query always includes 鞠婧祎 and results are filtered to artist-related images only. Returns direct image URLs, thumbnails, dimensions, and source article links. Enabled by default; set KIKU_ENABLE_CN_WEB_TOOLS=false to disable.",
774
896
  parameters: {
775
897
  type: "object",
776
898
  properties: {
777
- query: { type: "string", description: "Image search keyword or topic, e.g. '鞠婧祎 路透', '万花世界 剧照', 'Ju Jingyi red carpet'" },
899
+ keywords: { type: "string", description: "Canonical image keywords or topic, e.g. '鞠婧祎 万花世界 路透'" },
900
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"] },
901
+ domain_filter: { type: "array", items: { type: "string" }, description: "Optional image-source hostnames" },
902
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
778
903
  maxResults: { type: "number", description: "Max images to return (default 8, max 20)" }
779
904
  },
780
- required: ["query"]
905
+ required: []
781
906
  }
782
907
  }
783
908
  },
@@ -866,6 +991,94 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
866
991
  parameters: { type: "object", properties: {} }
867
992
  }
868
993
  },
994
+ {
995
+ type: "function",
996
+ function: {
997
+ name: "set_volume",
998
+ description: "Set the music playback volume level from 0 to 100 percent.",
999
+ parameters: {
1000
+ type: "object",
1001
+ properties: {
1002
+ volume: {
1003
+ type: "number",
1004
+ description: "Volume percentage between 0 and 100."
1005
+ }
1006
+ },
1007
+ required: ["volume"]
1008
+ }
1009
+ }
1010
+ },
1011
+ {
1012
+ type: "function",
1013
+ function: {
1014
+ name: "seek_music",
1015
+ description: "Seek or jump playback to a specific timestamp (in seconds or mm:ss format).",
1016
+ parameters: {
1017
+ type: "object",
1018
+ properties: {
1019
+ seconds: {
1020
+ type: "number",
1021
+ description: "Target playback position in seconds (e.g. 90)."
1022
+ },
1023
+ position: {
1024
+ type: "string",
1025
+ description: "Target position in mm:ss format (e.g. '01:30')."
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ },
1031
+ {
1032
+ type: "function",
1033
+ function: {
1034
+ name: "set_repeat_mode",
1035
+ description: "Set playback repeat or loop mode ('one' to repeat single track, 'all' to repeat playlist, 'off' to disable repeat).",
1036
+ parameters: {
1037
+ type: "object",
1038
+ properties: {
1039
+ mode: {
1040
+ type: "string",
1041
+ enum: ["one", "all", "off"],
1042
+ description: "Repeat mode: 'one', 'all', or 'off'."
1043
+ }
1044
+ },
1045
+ required: ["mode"]
1046
+ }
1047
+ }
1048
+ },
1049
+ {
1050
+ type: "function",
1051
+ function: {
1052
+ name: "set_shuffle_mode",
1053
+ description: "Enable or disable random shuffle playback.",
1054
+ parameters: {
1055
+ type: "object",
1056
+ properties: {
1057
+ enabled: {
1058
+ type: "boolean",
1059
+ description: "True to enable shuffle, false to disable."
1060
+ }
1061
+ },
1062
+ required: ["enabled"]
1063
+ }
1064
+ }
1065
+ },
1066
+ {
1067
+ type: "function",
1068
+ function: {
1069
+ name: "toggle_video_mode",
1070
+ description: "Toggle music video (MV) display view in the music player.",
1071
+ parameters: {
1072
+ type: "object",
1073
+ properties: {
1074
+ showVideo: {
1075
+ type: "boolean",
1076
+ description: "True to show video, false to hide video."
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ },
869
1082
  {
870
1083
  type: "function",
871
1084
  function: {
@@ -924,12 +1137,11 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
924
1137
  parameters: {
925
1138
  type: "object",
926
1139
  properties: {
927
- query: {
928
- type: "string",
929
- description: "Search query for YouTube."
930
- }
1140
+ query: { type: "string", description: "Deprecated compatibility alias for keywords" },
1141
+ keywords: { type: "string", description: "Canonical YouTube search keywords, e.g. 'Ju Jingyi FIND THE WAY'" },
1142
+ target_language: { type: "string", enum: ["zh", "th", "en", "ja", "ko"] }
931
1143
  },
932
- required: ["query"]
1144
+ required: []
933
1145
  }
934
1146
  }
935
1147
  },
@@ -1191,6 +1403,85 @@ Searches raw text catalog across all unindexed cross-domain documents.`,
1191
1403
  }
1192
1404
  ];
1193
1405
 
1406
+ export const TOOL_CAPABILITIES = {
1407
+ get_current_datetime: ['temporal'],
1408
+ get_user_location: ['system_location', 'temporal', 'external_retrieval'],
1409
+ search_location_map: ['system_location', 'map_navigation', 'external_retrieval'],
1410
+ get_weather: ['system_location', 'weather_forecast', 'external_retrieval'],
1411
+ get_youtube_transcript: ['media_transcript', 'live_verification'],
1412
+ get_profile_and_fandom: ['catalog_profile', 'biography'],
1413
+ get_quotes: ['catalog_profile'],
1414
+ search_works: ['catalog_works', 'filmography'],
1415
+ get_work_detail: ['catalog_works', 'filmography'],
1416
+ get_character_roles: ['catalog_works', 'filmography'],
1417
+ search_discography: ['catalog_music', 'discography'],
1418
+ get_music_videos: ['catalog_music', 'media'],
1419
+ get_media_links: ['catalog_media'],
1420
+ get_brand_endorsements: ['catalog_brands', 'commercial'],
1421
+ get_career_timeline: ['catalog_timeline', 'biography'],
1422
+ get_awards: ['catalog_awards', 'honors'],
1423
+ get_social_feed: ['catalog_social'],
1424
+ get_gallery_photos: ['catalog_gallery', 'images'],
1425
+ global_search: ['catalog_omni', 'search'],
1426
+ web_search: ['web_search', 'live_verification', 'external_retrieval'],
1427
+ fetch_webpage: ['web_content', 'live_verification', 'external_retrieval'],
1428
+ fetch_api: ['external_api'],
1429
+ fetch_json: ['external_api'],
1430
+ read_local_file: ['file_system'],
1431
+ list_directory: ['file_system'],
1432
+ screenshot_webpage: ['web_render', 'visual'],
1433
+ web_extract: ['web_content', 'live_verification', 'external_retrieval'],
1434
+ search_news: ['news', 'live_verification', 'temporal'],
1435
+ fetch_rss: ['news', 'live_verification', 'temporal'],
1436
+ search_encyclopedia: ['encyclopedia', 'live_verification'],
1437
+ search_music_platforms: ['music_search', 'live_verification'],
1438
+ search_chinese_web: ['chinese_web', 'live_verification', 'external_retrieval'],
1439
+ search_bilibili: ['chinese_video', 'live_verification', 'media'],
1440
+ search_web_images: ['images', 'live_verification'],
1441
+ get_system_stats: ['system_telemetry'],
1442
+ play_music: ['music_control', 'audio_playback'],
1443
+ stop_music: ['music_control', 'audio_playback'],
1444
+ mute_music: ['music_control', 'audio_playback'],
1445
+ unmute_music: ['music_control', 'audio_playback'],
1446
+ pause_music: ['music_control', 'audio_playback'],
1447
+ resume_music: ['music_control', 'audio_playback'],
1448
+ next_track: ['music_control', 'audio_playback'],
1449
+ prev_track: ['music_control', 'audio_playback'],
1450
+ set_volume: ['music_control', 'audio_playback'],
1451
+ seek_music: ['music_control', 'audio_playback'],
1452
+ set_repeat_mode: ['music_control', 'audio_playback'],
1453
+ set_shuffle_mode: ['music_control', 'audio_playback'],
1454
+ toggle_video_mode: ['music_control', 'video_playback'],
1455
+ add_to_queue: ['music_control', 'queue_management'],
1456
+ remove_from_queue: ['music_control', 'queue_management'],
1457
+ clear_queue: ['music_control', 'queue_management'],
1458
+ get_queue: ['music_control', 'queue_management'],
1459
+ search_youtube: ['music_search', 'video_search'],
1460
+ get_current_track: ['music_control', 'audio_status'],
1461
+ get_music_status: ['music_control', 'audio_status'],
1462
+ run_subagent: ['autonomous_agent'],
1463
+ run_deep_research: ['autonomous_agent', 'research'],
1464
+ verify_claim: ['fact_check', 'live_verification'],
1465
+ suggest_topics: ['recommendation'],
1466
+ manage_skill: ['skill_management'],
1467
+ run_quiz: ['entertainment', 'quiz'],
1468
+ get_api_key: ['system_auth'],
1469
+ get_entertainment_metrics: ['industry_metrics'],
1470
+ get_industry_index: ['industry_metrics'],
1471
+ checkpoint_step: ['memory_checkpoint'],
1472
+ record_failure: ['memory_failure'],
1473
+ load_active_run: ['memory_context'],
1474
+ record_dynamic_event: ['catalog_write']
1475
+ };
1476
+
1477
+ for (const item of toolsSchema) {
1478
+ const name = item.function?.name;
1479
+ if (name && TOOL_CAPABILITIES[name]) {
1480
+ item.capabilities = TOOL_CAPABILITIES[name];
1481
+ if (item.function) item.function.capabilities = TOOL_CAPABILITIES[name];
1482
+ }
1483
+ }
1484
+
1194
1485
 
1195
1486
  const TOOL_DEFAULT_TIMEOUTS = {
1196
1487
  run_subagent: 180_000,
@@ -1211,9 +1502,10 @@ const TOOL_DEFAULT_TIMEOUTS = {
1211
1502
  search_web_images: 60_000,
1212
1503
  };
1213
1504
 
1214
- const isCnWebEnabled = () => process.env.KIKU_ENABLE_CN_WEB_TOOLS === 'true';
1505
+ const CN_WEB_DISABLED_VALUES = new Set(['false', '0', 'off', 'no']);
1506
+ const isCnWebEnabled = () => !CN_WEB_DISABLED_VALUES.has((process.env.KIKU_ENABLE_CN_WEB_TOOLS ?? '').trim().toLowerCase());
1215
1507
  const cnWebDisabledMessage = (toolName) =>
1216
- `"${toolName}" is disabled. Set KIKU_ENABLE_CN_WEB_TOOLS=true to enable Chinese web tools (Baidu/Baike/Bilibili scraping — these sites use anti-bot protection and may be unreliable). Meanwhile use web_search, search_news, or search_encyclopedia.`;
1508
+ `"${toolName}" is disabled because KIKU_ENABLE_CN_WEB_TOOLS="${(process.env.KIKU_ENABLE_CN_WEB_TOOLS ?? '').trim()}" is set in the environment. Remove that line (or set KIKU_ENABLE_CN_WEB_TOOLS=true) in your .env file — the package-root .env for repo checkouts, or ${USER_ENV_PATH} for global installs — to enable Chinese web tools (Baidu/Baike/Bilibili scraping — these sites use anti-bot protection and may be unreliable). Meanwhile use web_search, search_news, or search_encyclopedia.`;
1217
1509
 
1218
1510
  async function ensureLlmAvailable(client) {
1219
1511
  if (!client || typeof client.checkConnection !== 'function') return null;
@@ -1261,6 +1553,301 @@ export const toolHandlers = {
1261
1553
  };
1262
1554
  },
1263
1555
 
1556
+ get_user_location: async (args = {}, options = {}) => {
1557
+ const passedLoc = options.clientLocation || options.location || options.agent?.clientLocation;
1558
+
1559
+ if (passedLoc && passedLoc.denied) {
1560
+ return {
1561
+ status: "permission_denied_or_unavailable",
1562
+ location_granted: false,
1563
+ source: "client_denied",
1564
+ message: "User declined to share geolocation coordinates. Continuing with fallback or asking user to specify city name."
1565
+ };
1566
+ }
1567
+
1568
+ const rawQuery = String(args.query || args.place || args.location || '').trim();
1569
+ if (rawQuery && !passedLoc && typeof args.latitude !== 'number') {
1570
+ const searchResults = await searchLocationOsm(rawQuery, options);
1571
+ if (searchResults && searchResults.length > 0) {
1572
+ const top = searchResults[0];
1573
+ const nearest = findNearestLandmark(top.latitude, top.longitude);
1574
+ const weather = await fetchRealtimeWeather(top.latitude, top.longitude, options);
1575
+ const confidence = scoreLocationConfidence({
1576
+ source: "osm_query_search",
1577
+ accuracy: null
1578
+ });
1579
+
1580
+ let summaryParts = [`Location resolved for "${rawQuery}": ${top.displayName} (${top.latitude.toFixed(4)}, ${top.longitude.toFixed(4)})`];
1581
+ if (nearest) {
1582
+ summaryParts.push(nearest.summaryTh);
1583
+ }
1584
+ if (weather && weather.summaryTh) {
1585
+ summaryParts.push(weather.summaryTh);
1586
+ }
1587
+ summaryParts.push(top.mapTag);
1588
+
1589
+ return {
1590
+ status: "success",
1591
+ location_granted: true,
1592
+ source: "osm_query_search",
1593
+ query: rawQuery,
1594
+ coordinates: { latitude: top.latitude, longitude: top.longitude },
1595
+ latitude: top.latitude,
1596
+ longitude: top.longitude,
1597
+ formatted_address: top.displayName,
1598
+ subdistrict: top.subdistrict,
1599
+ district: top.district,
1600
+ city: top.city || top.province || rawQuery,
1601
+ province: top.province,
1602
+ country: top.country,
1603
+ postcode: top.postcode,
1604
+ weather,
1605
+ nearest_landmark: nearest,
1606
+ confidence,
1607
+ map_url: top.mapUrl,
1608
+ map_tag: top.mapTag,
1609
+ results: searchResults,
1610
+ message: summaryParts.join('. ')
1611
+ };
1612
+ }
1613
+ }
1614
+
1615
+ const hasExplicitCoords = typeof args.latitude === 'number' && typeof args.longitude === 'number';
1616
+ const hasPassedCoords = passedLoc && (typeof passedLoc.latitude === 'number' || typeof passedLoc.lat === 'number');
1617
+
1618
+ if (hasExplicitCoords || hasPassedCoords) {
1619
+ const lat = hasExplicitCoords ? Number(args.latitude) : Number(passedLoc.latitude ?? passedLoc.lat);
1620
+ const lon = hasExplicitCoords ? Number(args.longitude) : Number(passedLoc.longitude ?? passedLoc.lon ?? passedLoc.lng);
1621
+ const accuracy = passedLoc?.accuracy || null;
1622
+ const tz = String(passedLoc?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Bangkok');
1623
+
1624
+ const geo = await reverseGeocodeOsm(lat, lon, options);
1625
+ const nearest = findNearestLandmark(lat, lon);
1626
+ const weather = await fetchRealtimeWeather(lat, lon, options);
1627
+
1628
+ const city = String(geo?.city || passedLoc?.city || passedLoc?.locality || (nearest ? nearest.landmark.city : 'Bangkok'));
1629
+ const country = String(geo?.country || passedLoc?.country || (nearest ? nearest.landmark.country : 'Thailand'));
1630
+ const region = String(geo?.province || passedLoc?.region || passedLoc?.state || city);
1631
+ const subdistrict = String(geo?.subdistrict || '');
1632
+ const district = String(geo?.district || '');
1633
+ const postcode = String(geo?.postcode || '');
1634
+ const formattedAddress = String(geo?.displayName || `${district ? district + ', ' : ''}${city}, ${country}`);
1635
+ const mapUrl = geo?.mapUrl || buildOsmUrl(lat, lon);
1636
+ const mapTag = geo?.mapTag || buildOsmMapTag(lat, lon, district || city || 'Location');
1637
+
1638
+ let summaryParts = [`User location retrieved: ${formattedAddress} (${lat.toFixed(4)}, ${lon.toFixed(4)})`];
1639
+ if (nearest) {
1640
+ summaryParts.push(nearest.summaryTh);
1641
+ }
1642
+ if (weather && weather.summaryTh) {
1643
+ summaryParts.push(weather.summaryTh);
1644
+ }
1645
+ summaryParts.push(mapTag);
1646
+
1647
+ return {
1648
+ status: "success",
1649
+ location_granted: true,
1650
+ source: hasExplicitCoords ? "explicit_coordinates" : "client_location",
1651
+ coordinates: { latitude: lat, longitude: lon },
1652
+ latitude: lat,
1653
+ longitude: lon,
1654
+ accuracy,
1655
+ formatted_address: formattedAddress,
1656
+ subdistrict,
1657
+ district,
1658
+ city,
1659
+ region,
1660
+ province: geo?.province || region,
1661
+ country,
1662
+ country_code: geo?.countryCode || '',
1663
+ postcode,
1664
+ timezone: tz,
1665
+ weather,
1666
+ nearest_landmark: nearest,
1667
+ confidence: scoreLocationConfidence({
1668
+ source: hasExplicitCoords ? "explicit_coordinates" : "client_location",
1669
+ accuracy
1670
+ }),
1671
+ map_url: mapUrl,
1672
+ map_tag: mapTag,
1673
+ message: summaryParts.join('. ')
1674
+ };
1675
+ }
1676
+
1677
+ const ipLoc = await detectIpLocation(options);
1678
+ const lat = ipLoc.latitude;
1679
+ const lon = ipLoc.longitude;
1680
+ const fallbackNearest = findNearestLandmark(lat, lon);
1681
+ const weather = await fetchRealtimeWeather(lat, lon, options);
1682
+
1683
+ let summaryParts = [`Location detected: ${ipLoc.city}, ${ipLoc.country} (${lat.toFixed(4)}, ${lon.toFixed(4)})`];
1684
+ if (fallbackNearest) {
1685
+ summaryParts.push(fallbackNearest.summaryTh);
1686
+ }
1687
+ if (weather && weather.summaryTh) {
1688
+ summaryParts.push(weather.summaryTh);
1689
+ }
1690
+ const mapTag = buildOsmMapTag(lat, lon, ipLoc.city);
1691
+ summaryParts.push(mapTag);
1692
+
1693
+ return {
1694
+ status: "success",
1695
+ location_granted: true,
1696
+ is_estimated_location: true,
1697
+ source: ipLoc.source,
1698
+ reason: args.reason || "weather_forecast",
1699
+ timezone: ipLoc.timezone,
1700
+ estimated_city: ipLoc.city,
1701
+ estimatedLocation: {
1702
+ city: ipLoc.city,
1703
+ region: ipLoc.region,
1704
+ country: ipLoc.country,
1705
+ latitude: lat,
1706
+ longitude: lon,
1707
+ timezone: ipLoc.timezone
1708
+ },
1709
+ latitude: lat,
1710
+ longitude: lon,
1711
+ city: ipLoc.city,
1712
+ country: ipLoc.country,
1713
+ weather,
1714
+ nearest_landmark: fallbackNearest,
1715
+ confidence: scoreLocationConfidence({
1716
+ source: ipLoc.source,
1717
+ accuracy: null
1718
+ }),
1719
+ map_url: buildOsmUrl(lat, lon),
1720
+ map_tag: mapTag,
1721
+ quickCityChoices: [
1722
+ { nameTh: 'กรุงเทพมหานคร', nameEn: 'Bangkok', latitude: 13.7563, longitude: 100.5018 },
1723
+ { nameTh: 'เชียงใหม่', nameEn: 'Chiang Mai', latitude: 18.7883, longitude: 98.9853 },
1724
+ { nameTh: 'ภูเก็ต', nameEn: 'Phuket', latitude: 7.8804, longitude: 98.3923 },
1725
+ { nameTh: 'ขอนแก่น', nameEn: 'Khon Kaen', latitude: 16.4419, longitude: 102.8360 },
1726
+ { nameTh: 'พัทยา', nameEn: 'Pattaya', latitude: 12.9276, longitude: 100.8771 }
1727
+ ],
1728
+ message: summaryParts.join('. ')
1729
+ };
1730
+ },
1731
+
1732
+ get_weather: async (args = {}, options = {}) => {
1733
+ let lat = null;
1734
+ let lon = null;
1735
+ let locationName = '';
1736
+ let mapTag = '';
1737
+ let isEstimated = false;
1738
+
1739
+ const query = String(args.location || args.place || args.city || '').trim();
1740
+ if (query) {
1741
+ const searchResults = await searchLocationOsm(query, options);
1742
+ if (searchResults && searchResults.length > 0) {
1743
+ const top = searchResults[0];
1744
+ lat = top.latitude;
1745
+ lon = top.longitude;
1746
+ locationName = top.displayName;
1747
+ mapTag = top.mapTag;
1748
+ }
1749
+ }
1750
+
1751
+ if (lat === null && typeof args.latitude === 'number' && typeof args.longitude === 'number') {
1752
+ lat = Number(args.latitude);
1753
+ lon = Number(args.longitude);
1754
+ locationName = `พิกัด (${lat.toFixed(4)}, ${lon.toFixed(4)})`;
1755
+ mapTag = buildOsmMapTag(lat, lon, locationName);
1756
+ }
1757
+
1758
+ const clientLoc = options.clientLocation || options.location || options.agent?.clientLocation;
1759
+ if (lat === null && clientLoc && typeof clientLoc.latitude === 'number') {
1760
+ lat = Number(clientLoc.latitude);
1761
+ lon = Number(clientLoc.longitude ?? clientLoc.lon);
1762
+ locationName = String(clientLoc.city || clientLoc.formatted_address || 'Current Location');
1763
+ mapTag = buildOsmMapTag(lat, lon, locationName);
1764
+ }
1765
+
1766
+ if (lat === null) {
1767
+ const ipLoc = await detectIpLocation(options);
1768
+ lat = ipLoc.latitude;
1769
+ lon = ipLoc.longitude;
1770
+ locationName = `${ipLoc.city}, ${ipLoc.country}`;
1771
+ mapTag = buildOsmMapTag(lat, lon, ipLoc.city);
1772
+ isEstimated = true;
1773
+ }
1774
+
1775
+ const weather = await fetchRealtimeWeather(lat, lon, options);
1776
+ const nearest = findNearestLandmark(lat, lon);
1777
+
1778
+ return {
1779
+ status: "success",
1780
+ location: locationName,
1781
+ latitude: lat,
1782
+ longitude: lon,
1783
+ is_estimated_location: isEstimated,
1784
+ current_weather: weather,
1785
+ nearest_landmark: nearest,
1786
+ confidence: scoreLocationConfidence({
1787
+ source: isEstimated ? 'ip_lookup' : (query ? 'osm_query_search' : 'explicit_coordinates'),
1788
+ accuracy: null
1789
+ }),
1790
+ map_tag: mapTag,
1791
+ message: weather ?
1792
+ `สภาพอากาศ ณ ${locationName}: ${weather.summaryTh} ${nearest ? '(' + nearest.summaryTh + ')' : ''}. ${mapTag}` :
1793
+ `ตำแหน่งที่ตั้ง: ${locationName}. ไม่สามารถดึงข้อมูลสภาพอากาศได้ในขณะนี้ ${mapTag}`
1794
+ };
1795
+ },
1796
+
1797
+ search_location_map: async (args = {}, options = {}) => {
1798
+ const rawQuery = String(args.query || args.location || args.place || '').trim();
1799
+ if (!rawQuery) {
1800
+ return {
1801
+ status: "error",
1802
+ message: "Parameter 'query' is required to search for a location map."
1803
+ };
1804
+ }
1805
+
1806
+ const limit = Number(args.limit) > 0 ? Number(args.limit) : 5;
1807
+ const results = await searchLocationOsm(rawQuery, { ...options, limit });
1808
+
1809
+ if (!results || results.length === 0) {
1810
+ return {
1811
+ status: "success",
1812
+ count: 0,
1813
+ query: rawQuery,
1814
+ results: [],
1815
+ message: `ไม่พบข้อมูลพิกัดสถานที่สำหรับ "${rawQuery}" บน OpenStreetMap กรุณาลองใช้ชื่อสถานที่หรือคำค้นหาอื่น`
1816
+ };
1817
+ }
1818
+
1819
+ const top = results[0];
1820
+ const nearest = findNearestLandmark(top.latitude, top.longitude);
1821
+ const weather = await fetchRealtimeWeather(top.latitude, top.longitude, options);
1822
+
1823
+ let summaryLines = [`พบพิกัดบน OpenStreetMap สำหรับ "${rawQuery}" ดังนี้:`];
1824
+ results.forEach((r, idx) => {
1825
+ summaryLines.push(`${idx + 1}. ${r.name} (${r.province || r.city || r.country}) [Lat: ${r.latitude.toFixed(4)}, Lon: ${r.longitude.toFixed(4)}]`);
1826
+ });
1827
+
1828
+ summaryLines.push(`\nพิกัดหลัก: ${top.displayName}`);
1829
+ if (nearest) {
1830
+ summaryLines.push(`สถานที่ใกล้เคียง: ${nearest.summaryTh}`);
1831
+ }
1832
+ if (weather && weather.summaryTh) {
1833
+ summaryLines.push(`สภาพอากาศ: ${weather.summaryTh}`);
1834
+ }
1835
+ summaryLines.push(`\nแผนที่ปักหมุด: ${top.mapTag}`);
1836
+
1837
+ return {
1838
+ status: "success",
1839
+ count: results.length,
1840
+ query: rawQuery,
1841
+ primaryLocation: top,
1842
+ nearest_landmark: nearest,
1843
+ weather,
1844
+ results,
1845
+ map_url: top.mapUrl,
1846
+ map_tag: top.mapTag,
1847
+ message: summaryLines.join('\n')
1848
+ };
1849
+ },
1850
+
1264
1851
  get_youtube_transcript: async (args = {}) => fetchYoutubeTranscript(args),
1265
1852
 
1266
1853
  get_profile_and_fandom: async ({ section, query } = {}) => {
@@ -1366,8 +1953,8 @@ export const toolHandlers = {
1366
1953
  broadMatch = true;
1367
1954
  } else {
1368
1955
  results = results.filter(w => matcher(
1369
- w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis,
1370
- ...(w.aliases || []), ...(w.keywords || [])
1956
+ w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis, w.category,
1957
+ ...(w.highlights || []), ...(w.aliases || []), ...(w.keywords || [])
1371
1958
  ));
1372
1959
  }
1373
1960
  }
@@ -1380,8 +1967,8 @@ export const toolHandlers = {
1380
1967
  let relaxed = [...baseWorks];
1381
1968
  const fallbackMatcher = matcher || createCatalogMatcher(query);
1382
1969
  relaxed = relaxed.filter(w => fallbackMatcher(
1383
- w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis,
1384
- ...(w.aliases || []), ...(w.keywords || [])
1970
+ w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis, w.category,
1971
+ ...(w.highlights || []), ...(w.aliases || []), ...(w.keywords || [])
1385
1972
  ));
1386
1973
  if (relaxed.length > 0) {
1387
1974
  results = relaxed;
@@ -2112,7 +2699,10 @@ export const toolHandlers = {
2112
2699
  const matcher = createCatalogMatcher(query);
2113
2700
 
2114
2701
  const matchedWorks = worksData.filter(w =>
2115
- matcher(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn)
2702
+ matcher(
2703
+ w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis, w.category,
2704
+ ...(w.highlights || []), ...(w.aliases || []), ...(w.keywords || [])
2705
+ )
2116
2706
  ).slice(0, 4).map(w => ({
2117
2707
  title: `${w.titleTh} (${w.year}) - Role: ${w.role}`,
2118
2708
  trailerYouTubeSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer`)
@@ -2167,8 +2757,15 @@ export const toolHandlers = {
2167
2757
  };
2168
2758
  },
2169
2759
 
2170
- web_search: async ({ query } = {}) => {
2171
- return await searchWeb(query);
2760
+ web_search: async (args = {}) => {
2761
+ const plan = planSearchRequest(args, { kind: 'web' });
2762
+ if (!plan.originalQuery) return { status: 'error', message: 'query parameter is required (use keywords for new callers).' };
2763
+ return await searchWeb(plan.keywords, undefined, {
2764
+ originalQuery: plan.originalQuery,
2765
+ targetLanguage: plan.targetLanguage,
2766
+ allowedDomains: plan.domains,
2767
+ queryPlan: plan,
2768
+ });
2172
2769
  },
2173
2770
 
2174
2771
  fetch_webpage: async ({ url } = {}) => {
@@ -2176,41 +2773,96 @@ export const toolHandlers = {
2176
2773
  },
2177
2774
 
2178
2775
  fetch_api: async ({ endpoint, query, baseUrl } = {}) => {
2179
- if (!endpoint) return { status: "error", message: "endpoint parameter is required (e.g. 'profile', 'works', 'search')" };
2180
- const base = (baseUrl || 'https://jujingyi-kikuuu.vercel.app/api/v1').replace(/\/$/, '');
2181
- const originCheck = sanitizeUrl(base);
2776
+ if (!endpoint) return { status: "error", message: "endpoint parameter is required (e.g. 'profile', 'works', 'discography', 'brands', 'timeline', 'awards')" };
2777
+ const defaultBase = 'https://jujingyi-kikuuu.vercel.app/api/v1';
2778
+ let rawBase = String(baseUrl || defaultBase).trim().replace(/\/+$/, '');
2779
+ if (!rawBase.includes('/api/v1')) {
2780
+ rawBase = `${rawBase}/api/v1`;
2781
+ }
2782
+ const originCheck = sanitizeUrl(rawBase, { allowLoopback: true });
2182
2783
  if (!originCheck.safe) {
2183
2784
  return { status: "error", message: `Unsafe API base URL: ${originCheck.reason}` };
2184
2785
  }
2786
+
2787
+ const isLocalOrigin = Boolean(originCheck.url && (
2788
+ originCheck.url.hostname === 'localhost' ||
2789
+ originCheck.url.hostname === '127.0.0.1' ||
2790
+ originCheck.url.hostname.endsWith('.local')
2791
+ ));
2792
+
2185
2793
  const allowedOrigins = new Set([
2186
2794
  'https://jujingyi-kikuuu.vercel.app',
2187
2795
  ...(process.env.KIKU_API_ALLOWED_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean)
2188
2796
  ]);
2189
- if (!originCheck.url || !allowedOrigins.has(originCheck.url.origin)) {
2797
+ if (!originCheck.url || (!isLocalOrigin && !allowedOrigins.has(originCheck.url.origin))) {
2190
2798
  return { status: 'error', message: 'API base origin is not allowlisted.' };
2191
2799
  }
2192
- const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
2193
- const target = new URL(`${base}/${endpoint.replace(/^\/+/, '')}`);
2194
- if (target.origin !== originCheck.url.origin || !target.pathname.startsWith('/api/v1/')) {
2195
- return { status: 'error', message: 'API endpoint must remain under /api/v1/.' };
2800
+
2801
+ let rawEndpoint = String(endpoint || '').trim();
2802
+ if (/^https?:\/\//i.test(rawEndpoint)) {
2803
+ try {
2804
+ const parsedEndpoint = new URL(rawEndpoint);
2805
+ if (parsedEndpoint.origin !== originCheck.url.origin && !isLocalOrigin) {
2806
+ return { status: 'error', message: `API endpoint must point to ${originCheck.url.origin}. For general external APIs, use fetch_json(url=...) instead.` };
2807
+ }
2808
+ rawEndpoint = `${parsedEndpoint.pathname}${parsedEndpoint.search}`;
2809
+ } catch {}
2810
+ }
2811
+
2812
+ let cleanPath = rawEndpoint.replace(/^\/?api\/v1\/?/i, '').replace(/^\/+/, '');
2813
+ const qIndex = cleanPath.indexOf('?');
2814
+ let pathPart = qIndex >= 0 ? cleanPath.slice(0, qIndex) : cleanPath;
2815
+ const queryPart = qIndex >= 0 ? cleanPath.slice(qIndex + 1) : '';
2816
+
2817
+ const ENDPOINT_ALIASES = {
2818
+ 'music': 'discography',
2819
+ 'songs': 'discography',
2820
+ 'tracks': 'discography',
2821
+ 'commercial': 'brands',
2822
+ 'endorsements': 'brands',
2823
+ 'milestones': 'timeline',
2824
+ 'history': 'timeline',
2825
+ 'honors': 'awards',
2826
+ 'drama': 'works',
2827
+ 'dramas': 'works',
2828
+ 'videos': 'mv',
2829
+ 'photos': 'gallery'
2830
+ };
2831
+
2832
+ const firstSegment = pathPart.split('/')[0].toLowerCase();
2833
+ if (ENDPOINT_ALIASES[firstSegment]) {
2834
+ const rest = pathPart.slice(firstSegment.length);
2835
+ pathPart = `${ENDPOINT_ALIASES[firstSegment]}${rest}`;
2836
+ }
2837
+
2838
+ const finalPath = `/${pathPart}`;
2839
+ const target = new URL(`${originCheck.url.origin}/api/v1${finalPath}`);
2840
+
2841
+ if (queryPart) {
2842
+ const existingParams = new URLSearchParams(queryPart);
2843
+ for (const [k, v] of existingParams.entries()) {
2844
+ target.searchParams.set(k, v);
2845
+ }
2846
+ }
2847
+ if (query) {
2848
+ target.searchParams.set('q', query);
2196
2849
  }
2197
- if (query) target.searchParams.set('q', query);
2198
2850
 
2199
2851
  try {
2200
- const result = await fetchJson(target.href, { timeoutMs: 10000, maxResponseLength: 100000 });
2852
+ const result = await fetchJson(target.href, { timeoutMs: 10000, maxResponseLength: 100000, allowLoopback: isLocalOrigin });
2201
2853
  if (result.status !== 'success') return result;
2202
2854
  const json = result.data;
2203
2855
  const data = json?.data || json;
2204
2856
  return {
2205
2857
  status: "success",
2206
- endpoint: path,
2858
+ endpoint: finalPath,
2207
2859
  count: Array.isArray(data) ? data.length : undefined,
2208
2860
  data
2209
2861
  };
2210
2862
  } catch (err) {
2211
2863
  return {
2212
2864
  status: "error",
2213
- endpoint: path,
2865
+ endpoint: finalPath,
2214
2866
  message: err.name === 'AbortError' ? 'API request timed out' : err.message
2215
2867
  };
2216
2868
  }
@@ -2220,42 +2872,59 @@ export const toolHandlers = {
2220
2872
  return await fetchJson(url, { method, headers, body });
2221
2873
  },
2222
2874
 
2223
- search_news: async ({ query, maxResults } = {}) => {
2224
- if (!query) return { status: 'error', message: 'query parameter is required.' };
2225
- return await searchNews(query, maxResults);
2875
+ search_news: async (args = {}) => {
2876
+ const plan = planSearchRequest(args, { kind: 'news' });
2877
+ if (!plan.originalQuery) return { status: 'error', message: 'query parameter is required (use keywords for new callers).' };
2878
+ return await searchNews(plan.keywords, args.maxResults, { queryPlan: plan });
2226
2879
  },
2227
2880
 
2228
2881
  fetch_rss: async ({ url, maxItems } = {}) => {
2229
2882
  return await fetchRssFeed(url, maxItems);
2230
2883
  },
2231
2884
 
2232
- search_encyclopedia: async ({ term, language, maxEntries } = {}) => {
2233
- if (!term) return { status: 'error', message: 'term parameter is required.' };
2234
- const lang = ['zh', 'th', 'en'].includes(language) ? language : detectLanguage(term);
2235
- return await searchEncyclopedia(term, lang, maxEntries);
2885
+ search_encyclopedia: async (args = {}) => {
2886
+ const plan = planSearchRequest({
2887
+ keywords: args.keywords || args.term || args.query || '',
2888
+ target_language: args.language || args.target_language,
2889
+ });
2890
+ if (!plan.originalQuery) return { status: 'error', message: 'term parameter is required (use keywords for new callers).' };
2891
+ const term = plan.entities[0]?.native || plan.keywords || plan.originalQuery;
2892
+ const lang = ['zh', 'th', 'en'].includes(args.language) ? args.language : detectLanguage(term);
2893
+ return await searchEncyclopedia(term, lang, args.maxEntries);
2236
2894
  },
2237
2895
 
2238
- search_music_platforms: async ({ term, maxResults } = {}) => {
2239
- if (!term) return { status: 'error', message: 'term parameter is required.' };
2240
- return await searchMusicPlatforms(term, maxResults);
2896
+ search_music_platforms: async (args = {}) => {
2897
+ const plan = planSearchRequest({
2898
+ keywords: args.keywords || args.term || args.query || '',
2899
+ target_language: args.target_language,
2900
+ });
2901
+ if (!plan.originalQuery) return { status: 'error', message: 'term parameter is required (use keywords for new callers).' };
2902
+ return await searchMusicPlatforms(plan.keywords || plan.originalQuery, args.maxResults);
2241
2903
  },
2242
2904
 
2243
- search_chinese_web: async ({ query, maxResults, site } = {}) => {
2905
+ search_chinese_web: async (args = {}) => {
2244
2906
  if (!isCnWebEnabled()) return { status: 'error', gated: true, message: cnWebDisabledMessage('search_chinese_web') };
2245
- if (!query) return { status: 'error', message: 'query parameter is required.' };
2246
- return await searchChineseWeb(query, maxResults, { site });
2907
+ const plan = planSearchRequest(args, { kind: 'chinese' });
2908
+ if (!plan.originalQuery) return { status: 'error', message: 'query parameter is required (use keywords for new callers).' };
2909
+ return await searchChineseWeb(plan.keywords, args.maxResults, {
2910
+ site: args.site,
2911
+ allowedDomains: plan.domains,
2912
+ queryPlan: plan,
2913
+ });
2247
2914
  },
2248
2915
 
2249
- search_bilibili: async ({ keyword, maxResults } = {}) => {
2916
+ search_bilibili: async (args = {}) => {
2250
2917
  if (!isCnWebEnabled()) return { status: 'error', gated: true, message: cnWebDisabledMessage('search_bilibili') };
2251
- if (!keyword) return { status: 'error', message: 'keyword parameter is required.' };
2252
- return await searchBilibili(keyword, maxResults);
2918
+ const plan = planSearchRequest(args, { kind: 'chinese' });
2919
+ if (!plan.originalQuery) return { status: 'error', message: 'keyword parameter is required (use keywords for new callers).' };
2920
+ return await searchBilibili(plan.keywords || plan.originalQuery, args.maxResults);
2253
2921
  },
2254
2922
 
2255
- search_web_images: async ({ query, maxResults } = {}) => {
2923
+ search_web_images: async (args = {}) => {
2256
2924
  if (!isCnWebEnabled()) return { status: 'error', gated: true, message: cnWebDisabledMessage('search_web_images') };
2257
- if (!query) return { status: 'error', message: 'query parameter is required.' };
2258
- return await searchWebImages(query, maxResults);
2925
+ const plan = planSearchRequest(args, { kind: 'image' });
2926
+ if (!plan.originalQuery) return { status: 'error', message: 'query parameter is required (use keywords for new callers).' };
2927
+ return await searchWebImages(plan.keywords, args.maxResults, { queryPlan: plan });
2259
2928
  },
2260
2929
 
2261
2930
  read_local_file: async ({ path: filePath, maxLines = 200 } = {}) => {
@@ -2420,56 +3089,147 @@ export const toolHandlers = {
2420
3089
 
2421
3090
  play_music: async (args) => {
2422
3091
  try {
2423
- const query = args.query || '';
3092
+ const query = (args.query || '').trim();
3093
+ if (!query) {
3094
+ return { status: 'error', action: 'play', message: 'No song query provided. Tell me a Ju Jingyi song or OST name.' };
3095
+ }
3096
+ const catalogMatch = resolveCatalogSong(query);
3097
+ const songInfo = catalogMatch ? catalogMatch : null;
3098
+ const effectiveTitle = songInfo?.title || query;
2424
3099
 
2425
3100
  const hasArtist = /jujingyi|จวีจิ้งอี|鞠婧祎/i.test(query);
2426
3101
  const isUrl = /^https?:\/\//.test(query);
2427
- const searchQuery = (!hasArtist && !isUrl) ? `Ju Jingyi ${query}` : query;
2428
- const result = await audioPlayer.play(searchQuery);
2429
- return { status: 'success', message: result, playing: args.query };
3102
+ const searchQuery = (!hasArtist && !isUrl) ? `Ju Jingyi ${effectiveTitle}` : effectiveTitle;
3103
+ const youtubeUrl = songInfo?.youtubeId
3104
+ ? buildYouTubeWatchUrl(songInfo.youtubeId)
3105
+ : buildYouTubeSearchUrl(searchQuery);
3106
+ let playMsg = '';
3107
+ let localPlayback = false;
3108
+ let playbackError = null;
3109
+ try {
3110
+ playMsg = await audioPlayer.play(searchQuery);
3111
+ localPlayback = true;
3112
+ } catch (err) {
3113
+
3114
+ playbackError = err?.message || 'Local audio unavailable';
3115
+ playMsg = `Local audio unavailable (${playbackError}). Listen here: ${youtubeUrl}`;
3116
+ }
3117
+ return {
3118
+ status: 'success',
3119
+ action: 'play',
3120
+ message: playMsg,
3121
+ playing: effectiveTitle,
3122
+ song: songInfo,
3123
+ youtubeUrl,
3124
+ localPlayback,
3125
+ ...(playbackError ? { playbackError } : {})
3126
+ };
2430
3127
  } catch (err) {
2431
- return { status: 'error', message: err.message };
3128
+ return { status: 'error', action: 'play', message: err.message };
2432
3129
  }
2433
3130
  },
2434
3131
 
2435
3132
  stop_music: async () => {
2436
- const result = await audioPlayer.stop();
2437
- return { status: 'success', message: result };
3133
+ const result = audioPlayer.stop();
3134
+ return { status: 'success', action: 'stop', message: `[OK] ${result || 'Playback stopped'}` };
2438
3135
  },
2439
3136
 
2440
3137
  mute_music: async () => {
2441
- const result = await audioPlayer.mute();
2442
- return { status: 'success', message: result };
3138
+ const result = audioPlayer.mute();
3139
+
3140
+ if (result && result.includes('No song')) {
3141
+ return { status: 'error', action: 'mute', message: result };
3142
+ }
3143
+ return { status: 'success', action: 'mute', message: `[OK] ${result || 'Muted'}` };
2443
3144
  },
2444
3145
 
2445
3146
  unmute_music: async () => {
2446
- const result = await audioPlayer.unmute();
2447
- return { status: 'success', message: result };
3147
+ const result = audioPlayer.unmute();
3148
+ if (result && result.includes('No song')) {
3149
+ return { status: 'error', action: 'unmute', message: result };
3150
+ }
3151
+ return { status: 'success', action: 'unmute', message: `[OK] ${result || 'Unmuted'}` };
2448
3152
  },
2449
3153
 
2450
3154
  pause_music: async () => {
2451
- const result = await audioPlayer.pause();
2452
- return { status: 'success', message: result };
3155
+ const result = audioPlayer.pause();
3156
+ if (result && (result.includes('No song') || result.includes('already paused'))) {
3157
+ const isIdle = result.includes('No song');
3158
+ return { status: isIdle ? 'error' : 'success', action: 'pause', message: result };
3159
+ }
3160
+ return { status: 'success', action: 'pause', message: `[OK] ${result || 'Playback paused'}` };
2453
3161
  },
2454
3162
 
2455
3163
  resume_music: async () => {
2456
- const result = await audioPlayer.resume();
2457
- return { status: 'success', message: result };
3164
+ const result = audioPlayer.resume();
3165
+ if (result && result.includes('No track')) {
3166
+ return { status: 'error', action: 'resume', message: result };
3167
+ }
3168
+ return { status: 'success', action: 'resume', message: `[OK] ${result || 'Playback resumed'}` };
2458
3169
  },
2459
3170
 
2460
3171
  next_track: async () => {
2461
- const result = await audioPlayer.next();
2462
- return { status: 'success', message: result };
3172
+ const result = audioPlayer.next();
3173
+ if (result && (result.includes('No next track') || result.includes('No track') || result.includes('No song'))) {
3174
+ return { status: 'error', action: 'next', message: result };
3175
+ }
3176
+ return { status: 'success', action: 'next', message: `[OK] ${result || 'Skipped to next track'}`, currentTrack: audioPlayer.currentTrack };
2463
3177
  },
2464
3178
 
2465
3179
  prev_track: async () => {
2466
- const result = await audioPlayer.prev();
2467
- return { status: 'success', message: result };
3180
+ const result = audioPlayer.prev();
3181
+ if (result && (result.includes('No previous track') || result.includes('No track') || result.includes('No song'))) {
3182
+ return { status: 'error', action: 'prev', message: result };
3183
+ }
3184
+ return { status: 'success', action: 'prev', message: `[OK] ${result || 'Skipped to previous track'}`, currentTrack: audioPlayer.currentTrack };
3185
+ },
3186
+
3187
+ set_volume: async (args) => {
3188
+ const vol = Math.max(0, Math.min(100, Number(args?.volume) || 0));
3189
+ const result = audioPlayer.setVolume(vol);
3190
+ return { status: 'success', action: 'set_volume', volume: vol, message: result };
3191
+ },
3192
+
3193
+ seek_music: async (args) => {
3194
+ let targetSec = 0;
3195
+ if (typeof args?.seconds === 'number') {
3196
+ targetSec = Math.max(0, args.seconds);
3197
+ } else if (typeof args?.position === 'string') {
3198
+ const parts = args.position.split(':').map(Number);
3199
+ if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
3200
+ targetSec = parts[0] * 60 + parts[1];
3201
+ } else if (!isNaN(Number(args.position))) {
3202
+ targetSec = Number(args.position);
3203
+ }
3204
+ }
3205
+ const result = audioPlayer.seek(targetSec);
3206
+ return { status: 'success', action: 'seek', seconds: targetSec, message: result };
3207
+ },
3208
+
3209
+ set_repeat_mode: async (args) => {
3210
+ const mode = String(args?.mode || 'off').toLowerCase().trim();
3211
+ const result = audioPlayer.setRepeatMode(mode);
3212
+ return { status: 'success', action: 'set_repeat', mode: audioPlayer.repeatMode, message: result };
3213
+ },
3214
+
3215
+ set_shuffle_mode: async (args) => {
3216
+ const enabled = args?.enabled !== undefined ? Boolean(args.enabled) : !audioPlayer.isShuffle;
3217
+ const result = audioPlayer.setShuffleMode(enabled);
3218
+ return { status: 'success', action: 'set_shuffle', enabled, message: result };
3219
+ },
3220
+
3221
+ toggle_video_mode: async (args) => {
3222
+ const show = args?.showVideo !== undefined ? Boolean(args.showVideo) : true;
3223
+ return { status: 'success', action: 'toggle_video', isVideoOpen: show, message: `Video display mode: ${show ? 'shown' : 'hidden'}` };
2468
3224
  },
2469
3225
 
2470
3226
  add_to_queue: async (args) => {
2471
- const result = await audioPlayer.addToQueue(args.query);
2472
- return { status: 'success', message: result };
3227
+ try {
3228
+ const result = await audioPlayer.addToQueue(args.query);
3229
+ return { status: 'success', message: result };
3230
+ } catch (err) {
3231
+ return { status: 'error', message: err?.message || 'Could not add track to queue' };
3232
+ }
2473
3233
  },
2474
3234
 
2475
3235
  remove_from_queue: async (args) => {
@@ -2492,10 +3252,13 @@ export const toolHandlers = {
2492
3252
  return { status: 'success', queue };
2493
3253
  },
2494
3254
 
2495
- search_youtube: async (args) => {
3255
+ search_youtube: async (args = {}) => {
2496
3256
  try {
2497
- const results = await audioPlayer.searchYouTube(args.query);
2498
- return { status: 'success', query: args.query, results };
3257
+ const plan = planSearchRequest(args);
3258
+ const query = plan.keywords || plan.originalQuery || args.query || '';
3259
+ if (!query) return { status: 'error', message: 'query parameter is required (use keywords for new callers).' };
3260
+ const results = await audioPlayer.searchYouTube(query);
3261
+ return { status: 'success', query: plan.originalQuery || query, normalizedQuery: plan.keywords || query, results };
2499
3262
  } catch (err) {
2500
3263
  return { status: 'error', message: err.message };
2501
3264
  }
@@ -3056,5 +3819,23 @@ export const toolsRegistry = {
3056
3819
  schema: toolsSchema.find(t => t.function?.name === 'search_works')?.function,
3057
3820
  handler: toolHandlers.search_works,
3058
3821
  },
3822
+ get_user_location: {
3823
+ domain: 'location',
3824
+ isCore: false,
3825
+ schema: toolsSchema.find(t => t.function?.name === 'get_user_location')?.function,
3826
+ handler: toolHandlers.get_user_location,
3827
+ },
3828
+ search_location_map: {
3829
+ domain: 'location',
3830
+ isCore: false,
3831
+ schema: toolsSchema.find(t => t.function?.name === 'search_location_map')?.function,
3832
+ handler: toolHandlers.search_location_map,
3833
+ },
3834
+ get_weather: {
3835
+ domain: 'location',
3836
+ isCore: false,
3837
+ schema: toolsSchema.find(t => t.function?.name === 'get_weather')?.function,
3838
+ handler: toolHandlers.get_weather,
3839
+ },
3059
3840
  };
3060
3841