kiku-agent 1.0.11 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +300 -495
  2. package/package.json +12 -11
  3. package/public/fonts/GoogleSans-Bold.ttf +0 -0
  4. package/public/fonts/GoogleSans-Bold.woff2 +0 -0
  5. package/public/fonts/GoogleSans-BoldItalic.ttf +0 -0
  6. package/public/fonts/GoogleSans-Italic.ttf +0 -0
  7. package/public/fonts/GoogleSans-Latin-400.woff2 +0 -0
  8. package/public/fonts/GoogleSans-Latin-500.woff2 +0 -0
  9. package/public/fonts/GoogleSans-Latin-600.woff2 +0 -0
  10. package/public/fonts/GoogleSans-Latin-700.woff2 +0 -0
  11. package/public/fonts/GoogleSans-Medium.ttf +0 -0
  12. package/public/fonts/GoogleSans-Medium.woff2 +0 -0
  13. package/public/fonts/GoogleSans-Regular.ttf +0 -0
  14. package/public/fonts/GoogleSans-Regular.woff2 +0 -0
  15. package/public/fonts/GoogleSans-Thai-400.woff2 +0 -0
  16. package/public/fonts/GoogleSans-Thai-500.woff2 +0 -0
  17. package/public/fonts/GoogleSans-Thai-600.woff2 +0 -0
  18. package/public/fonts/GoogleSans-Thai-700.woff2 +0 -0
  19. package/public/fonts/NotoSansThai-Bold.woff2 +0 -0
  20. package/public/fonts/NotoSansThai-Regular.woff2 +0 -0
  21. package/public/fonts/google-sans-official.css +900 -0
  22. package/public/images/0040jbadgy1id6am4yxqfj61s82dn1kx02.jpg +0 -0
  23. package/public/images/0040jbadgy1id6am708pbj62c0340qv602.jpg +0 -0
  24. package/public/images/0040jbadgy1id6amb81uqj62c0340x6q02.jpg +0 -0
  25. package/public/images/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
  26. package/public/images/0040jbadgy1id6amdg3xcj62b232wqv502.jpg +0 -0
  27. package/public/images/0040jbadgy1id6amexv23j620v2pau0x02.jpg +0 -0
  28. package/public/images/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
  29. package/public/images/008ET7Legy1ig9s89nri5j30i20n1gn4.jpg +0 -0
  30. package/public/images/008ET7Legy1ig9s8a2lmlj30i20ny409.jpg +0 -0
  31. package/public/images/008ET7Legy1ig9s8ay1hsj30i20nk3zv.jpg +0 -0
  32. package/public/images/008ET7Legy1ig9s8bab6sj30i20o7go1.jpg +0 -0
  33. package/public/images/008ET7Legy1ig9s8blqsxj30i20oiq4r.jpg +0 -0
  34. package/public/images/008ET7Legy1ig9s8cbxkzj30i20mxmzh.jpg +0 -0
  35. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-399915.jpg +0 -0
  36. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-424041.jpg +0 -0
  37. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-588166.jpg +0 -0
  38. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-672198.jpg +0 -0
  39. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-700522.jpg +0 -0
  40. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-839722.jpg +0 -0
  41. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026825-460586.jpg +0 -0
  42. package/public/images/douyin-update/thumbs//346/212/226/351/237/263202694-689407.jpg +0 -0
  43. package/public/images/douyin-update//346/212/226/351/237/2632026820-399915.mp4 +0 -0
  44. package/public/images/douyin-update//346/212/226/351/237/2632026820-424041.mp4 +0 -0
  45. package/public/images/douyin-update//346/212/226/351/237/2632026820-588166.mp4 +0 -0
  46. package/public/images/douyin-update//346/212/226/351/237/2632026820-672198.mp4 +0 -0
  47. package/public/images/douyin-update//346/212/226/351/237/2632026820-700522.mp4 +0 -0
  48. package/public/images/douyin-update//346/212/226/351/237/2632026820-839722.mp4 +0 -0
  49. package/public/images/douyin-update//346/212/226/351/237/2632026825-460586.mp4 +0 -0
  50. package/public/images/douyin-update//346/212/226/351/237/263202694-689407.mp4 +0 -0
  51. package/public/images/general/0040jbadgy1id6am4yxqfj61s82dn1kx02.jpg +0 -0
  52. package/public/images/general/0040jbadgy1id6am708pbj62c0340qv602.jpg +0 -0
  53. package/public/images/general/0040jbadgy1id6amb81uqj62c0340x6q02.jpg +0 -0
  54. package/public/images/general/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
  55. package/public/images/general/0040jbadgy1id6amdg3xcj62b232wqv502.jpg +0 -0
  56. package/public/images/general/0040jbadgy1id6amexv23j620v2pau0x02.jpg +0 -0
  57. package/public/images/general/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
  58. package/public/images/general/008ET7Legy1ig9s89nri5j30i20n1gn4.jpg +0 -0
  59. package/public/images/general/008ET7Legy1ig9s8a2lmlj30i20ny409.jpg +0 -0
  60. package/public/images/general/008ET7Legy1ig9s8ay1hsj30i20nk3zv.jpg +0 -0
  61. package/public/images/general/008ET7Legy1ig9s8bab6sj30i20o7go1.jpg +0 -0
  62. package/public/images/general/008ET7Legy1ig9s8blqsxj30i20oiq4r.jpg +0 -0
  63. package/public/images/general/008ET7Legy1ig9s8cbxkzj30i20mxmzh.jpg +0 -0
  64. package/public/images/general/jujingyi_portrait.jpg +0 -0
  65. package/public/images/jujingyi_portrait.jpg +0 -0
  66. package/public/images/series/a-millenniums-wait/poster.jpg +0 -0
  67. package/public/images/series/beauty-of-resilience/poster.jpg +0 -0
  68. package/public/images/series/detective-samoyeds/poster.jpg +0 -0
  69. package/public/images/series/everyday/poster.jpg +0 -0
  70. package/public/images/series/fu-rong-ancient-painting/poster.jpg +0 -0
  71. package/public/images/series/in-a-class-of-her-own/poster.jpg +0 -0
  72. package/public/images/series/in-blossom/poster.jpg +0 -0
  73. package/public/images/series/legend-of-yunxi/poster.jpg +0 -0
  74. package/public/images/series/lets-fight/poster-2.jpg +0 -0
  75. package/public/images/series/lets-fight/poster.jpg +0 -0
  76. package/public/images/series/love-emergency/poster.jpg +0 -0
  77. package/public/images/series/love-under-the-full-moon/poster.jpg +0 -0
  78. package/public/images/series/money-bastion/poster.jpg +0 -0
  79. package/public/images/series/mr-swimmer/poster.jpg +0 -0
  80. package/public/images/series/novoland-castle-in-the-sky/poster.jpg +0 -0
  81. package/public/images/series/please-give-me-a-pair-of-wings/poster.jpg +0 -0
  82. package/public/images/series/rebirth-for-you/poster.jpg +0 -0
  83. package/public/images/series/sword-and-fairy-4/poster.jpg +0 -0
  84. package/public/images/series/the-blooms-at-ruyi-pavilion/poster.jpg +0 -0
  85. package/public/images/series/the-eternal-fragrance/poster-2.jpg +0 -0
  86. package/public/images/series/the-eternal-fragrance/poster.jpg +0 -0
  87. package/public/images/series/the-fallen-flowers/poster.jpg +0 -0
  88. package/public/images/series/the-legend-of-white-snake/poster.jpg +0 -0
  89. package/public/images/series/veil-of-shadows/poster.jpg +0 -0
  90. package/public/images/series/wanhua-sin/poster-2.jpg +0 -0
  91. package/public/images/series/wanhua-sin/poster.jpg +0 -0
  92. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj10yogj62u03s0npe02.jpg +0 -0
  93. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj2pdvyj61li25jkjl02.jpg +0 -0
  94. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj71177j62672w9e8302.jpg +0 -0
  95. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj7twpfj62192197rw02.jpg +0 -0
  96. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj8jfu8j62c03401kx02.jpg +0 -0
  97. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj9dhdsj622k2l77wh02.jpg +0 -0
  98. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkja31s4j62bw3407r802.jpg +0 -0
  99. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjcf331j61402ey1kx02.jpg +0 -0
  100. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjdx8gqj62602pihdt02.jpg +0 -0
  101. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjfvxnzj62432n57wh02.jpg +0 -0
  102. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixniv2yj627y4fxb2b02.jpg +0 -0
  103. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixsvbdaj625k2vfu1102.jpg +0 -0
  104. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixxxdaoj62c03501l202.jpg +0 -0
  105. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixzeef9j62c0340npd02.jpg +0 -0
  106. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3iy0vs6tj626t4rbhdu02.jpg +0 -0
  107. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3iy21wwuj62bt34hx6q02.jpg +0 -0
  108. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly4wr85j62z629y7wh02.jpg +0 -0
  109. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly67oilj623u2t5npd02.jpg +0 -0
  110. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly9qtw8j62c0340u0x02.jpg +0 -0
  111. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlyem4m4j63402c01kz02.jpg +0 -0
  112. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlygkoq4j627s2yeavd02.jpg +0 -0
  113. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlyjh0daj62bw340nm102.jpg +0 -0
  114. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlylejlhj62bw3401kx02.jpg +0 -0
  115. package/public/images/weibo-update/today/0040jbadgy1igaiaa8b7fj627j2y67wh02.jpg +0 -0
  116. package/public/images/weibo-update/today/0040jbadgy1igaiagw3ezj61s02ebu0x02.jpg +0 -0
  117. package/public/images/weibo-update/today/0040jbadgy1igaiaivng3j61rv2ebx6p02.jpg +0 -0
  118. package/public/images/weibo-update/today/0040jbadgy1igaiajuixvj62bw340qv502.jpg +0 -0
  119. package/public/images/weibo-update/today/0040jbadgy1igaiapyartj62bw340hdt02.jpg +0 -0
  120. package/public/images/weibo-update/today/0040jbadgy1igaibkwu4ij61vy2iq1kx02.jpg +0 -0
  121. package/public/images/weibo-update/today/0040jbadgy1igaic1fvkpj61u92eo1ky02.jpg +0 -0
  122. package/public/images/weibo-update/today/0040jbadgy1igaic3cruhj62822ywb2902.jpg +0 -0
  123. package/public/manifest.json +25 -0
  124. package/public/mcp-logo-128.png +0 -0
  125. package/public/mcp-logo-64.png +0 -0
  126. package/public/mcp-logo.svg +16 -0
  127. package/server/agent/agentCore.js +542 -74
  128. package/server/agent/appPaths.js +1 -0
  129. package/server/agent/audioPlayer.js +4 -2
  130. package/server/agent/citations.js +294 -0
  131. package/server/agent/clarificationAnalytics.js +477 -0
  132. package/server/agent/clarifyingQuestions.js +1089 -0
  133. package/server/agent/cli.js +549 -125
  134. package/server/agent/commandFeatures.js +1 -0
  135. package/server/agent/commandRegistry.js +946 -879
  136. package/server/agent/commands.js +546 -272
  137. package/server/agent/config.js +581 -412
  138. package/server/agent/contextCompactor.js +2 -2
  139. package/server/agent/debate.js +235 -0
  140. package/server/agent/eventBus.js +3 -0
  141. package/server/agent/feedbackLearning.js +217 -0
  142. package/server/agent/llmClient.js +512 -394
  143. package/server/agent/mcpClient.js +182 -32
  144. package/server/agent/memory.js +283 -224
  145. package/server/agent/modelManager.js +79 -39
  146. package/server/agent/profiles.js +178 -0
  147. package/server/agent/publicTools.js +22 -0
  148. package/server/agent/queryDetails.js +68 -8
  149. package/server/agent/quizData.js +398 -384
  150. package/server/agent/research.js +121 -79
  151. package/server/agent/sanitizer.js +104 -13
  152. package/server/agent/session.js +319 -11
  153. package/server/agent/subagent.js +8 -3
  154. package/server/agent/suggestionTui.js +294 -0
  155. package/server/agent/suggestions.js +69 -3
  156. package/server/agent/telemetry.js +119 -0
  157. package/server/agent/terminalImage.js +146 -27
  158. package/server/agent/theme.js +17 -0
  159. package/server/agent/toolRouter.js +16 -1
  160. package/server/agent/tools.js +424 -225
  161. package/server/agent/ui.js +486 -64
  162. package/server/agent/version.js +2 -4
  163. package/server/agent/webSearch.js +111 -11
  164. package/server/agent/youtubeAuth.js +24 -6
  165. package/server/apiRouter.js +4222 -0
  166. package/server/mcp/data/discography.js +25 -16
  167. package/server/mcp/data/gallery.js +308 -20
  168. package/server/mcp/data/profile.js +74 -44
  169. package/server/mcp/data/timeline.js +1 -1
  170. package/server/mcp/data/works.js +51 -17
  171. package/server/mcp/dataAdapter.js +5 -3
  172. package/server/mcp/handler.js +5 -11
  173. package/server/mcp/prompts.js +86 -0
  174. package/server/mcp/security.js +18 -15
  175. package/server/mcp/server.js +7 -2
  176. package/server/mcp/tools.js +66 -2
  177. package/server/web-chat/core/browserLauncher.js +18 -0
  178. package/server/web-chat/core/connection.js +194 -0
  179. package/server/web-chat/core/conversationContext.js +82 -0
  180. package/server/web-chat/core/directCatalogEngine.js +196 -0
  181. package/server/web-chat/core/dramaKnowledge.js +142 -0
  182. package/server/web-chat/core/errorHandler.js +51 -0
  183. package/server/web-chat/core/followUpEngine.js +182 -0
  184. package/server/web-chat/core/futureEventGuard.js +80 -0
  185. package/server/web-chat/core/memorySnapshot.js +12 -0
  186. package/server/web-chat/core/security.js +146 -0
  187. package/server/web-chat/core/toolInspector.js +52 -0
  188. package/server/web-chat/router.js +120 -0
  189. package/server/web-chat/routes/chatRoute.js +397 -0
  190. package/server/web-chat/routes/clarifyConfigRoute.js +98 -0
  191. package/server/web-chat/routes/clarifyRoute.js +83 -0
  192. package/server/web-chat/routes/compareRoute.js +79 -0
  193. package/server/web-chat/routes/exportRoute.js +59 -0
  194. package/server/web-chat/routes/feedbackRoute.js +57 -0
  195. package/server/web-chat/routes/newsRoute.js +33 -0
  196. package/server/web-chat/routes/quizRoute.js +11 -0
  197. package/server/web-chat/routes/staticRoute.js +103 -0
  198. package/server/web-chat/routes/statusRoute.js +52 -0
  199. package/server/web-chat/routes/suggestRoute.js +77 -0
  200. package/server/web-chat/routes/timelineRoute.js +34 -0
  201. package/server/web-chat/routes/verifyRoute.js +57 -0
  202. package/server/web-chat/server.js +116 -0
  203. package/server/web-chat/ui/htmlBuilder.js +91 -0
  204. package/server/web-chat/ui/previews/agentBubble.preview.html +134 -0
  205. package/server/web-chat/ui/scripts/agentBubbleSystem.js +320 -0
  206. package/server/web-chat/ui/scripts/client.js +8397 -0
  207. package/server/web-chat/ui/scripts/kikuDb.js +177 -0
  208. package/server/web-chat/ui/scripts/suggestionsData.js +118 -0
  209. package/server/web-chat/ui/scripts/webchatI18n.js +291 -0
  210. package/server/web-chat/ui/styles/main.css +8568 -0
  211. package/server/web-chat/ui/templates/body.html +796 -0
  212. package/server/webChatHtml.js +1 -0
  213. package/server/webChatServer.js +6 -0
  214. package/src/data/discographyData.js +892 -866
  215. package/src/data/galleryData.js +1805 -561
  216. package/src/data/profileData.js +79 -2
  217. package/src/data/socialUpdatesData.js +106 -47
  218. package/src/data/timelineData.js +1 -1
  219. package/src/data/worksData.js +25 -4
  220. package/tokens.css +97 -0
  221. package/server/agent/updateManager.js +0 -456
@@ -8,10 +8,10 @@ import { galleryData } from '../../src/data/galleryData.js';
8
8
  import { awardsData } from '../../src/data/awardsData.js';
9
9
  import { timelineData } from '../../src/data/timelineData.js';
10
10
  import { searchWeb, fetchWebpage, fetchJson, screenshotWebpage, webExtract } from './webSearch.js';
11
- import { readFile, readdir, stat } from 'node:fs/promises';
11
+ import { readFile, readdir, stat, realpath } from 'node:fs/promises';
12
12
  import { join, relative, extname, resolve } from 'node:path';
13
13
  import { audioPlayer } from './audioPlayer.js';
14
- import { sanitizeToolArgs } from './sanitizer.js';
14
+ import { sanitizeToolArgs, sanitizeUrl } from './sanitizer.js';
15
15
  import { getGpuMetrics } from './gpuMetrics.js';
16
16
 
17
17
  export function buildYouTubeSearchUrl(query) {
@@ -30,16 +30,48 @@ export function buildQqMusicSearchUrl(query) {
30
30
  return `https://y.qq.com/n/ryqq/search?w=${encodeURIComponent(query)}`;
31
31
  }
32
32
 
33
- export function buildYoukuSearchUrl(query) {
34
- return `https://so.youku.com/search_video/q_${encodeURIComponent(query)}`;
35
- }
36
-
37
- export function formatCatalogTitle(primary, secondary) {
38
- const first = String(primary || secondary || '').trim();
39
- const second = String(secondary || '').trim();
40
- if (!second || first.toLocaleLowerCase().includes(second.toLocaleLowerCase())) return first;
41
- return `${first} (${second})`;
42
- }
33
+ export function buildYoukuSearchUrl(query) {
34
+ return `https://so.youku.com/search_video/q_${encodeURIComponent(query)}`;
35
+ }
36
+
37
+ export function formatCatalogTitle(primary, secondary) {
38
+ const first = String(primary || secondary || '').trim();
39
+ const second = String(secondary || '').trim();
40
+ if (!second || first.toLocaleLowerCase().includes(second.toLocaleLowerCase())) return first;
41
+ return `${first} (${second})`;
42
+ }
43
+
44
+ export function createCatalogMatcher(query) {
45
+ if (!query) return () => true;
46
+ const raw = String(query).toLowerCase().trim();
47
+ if (!raw) return () => true;
48
+
49
+ const stopWords = new Set([
50
+ 'the', 'from', 'what', 'are', 'is', 'about', 'drama', 'series', 'ost', 'song', 'songs', 'soundtrack', 'music', 'single', 'album', 'stage',
51
+ 'brand', 'brands', 'ambassador', 'endorsement', 'commercial', 'role', 'roles', 'character', 'characters', 'award', 'awards', 'category', 'video', 'mv', 'clip',
52
+ 'ju', 'jingyi', 'jujingyi', 'kiku', 'kikuuu', 'จวีจิ้งอี', 'จวี', 'จิ้งอี', 'เสี่ยวจวี', 'เสี่ยว', '鞠婧祎', '小鞠', '鞠',
53
+ 'เพลง', 'เพลงของ', 'ซีรีส์', 'ซีรีส์ของ', 'ละคร', 'เรื่อง', 'ผลงาน', 'อัลบั้ม', 'คอนเสิร์ต', 'บทบาท', 'ตัวละคร', 'แบรนด์', 'รางวัล', 'ผลงานล่าสุด', 'พรีเซนเตอร์'
54
+ ]);
55
+
56
+ const allTokens = raw.split(/[^\p{L}\p{M}\p{N}]+/u).filter(t => t.length >= 2);
57
+
58
+ const deDated = raw.replace(/ใหม่ล่าสุด|ล่าสุด|最新|latest|newest|recent/gi, ' ');
59
+ const datedTokens = deDated.split(/[^\p{L}\p{M}\p{N}]+/u).filter(t => t.length >= 2);
60
+ const coreTokens = datedTokens.filter(t => !stopWords.has(t));
61
+
62
+ if (coreTokens.length === 0) return () => true;
63
+ const effectiveTokens = coreTokens;
64
+
65
+ return (...fields) => {
66
+ const haystack = fields.filter(Boolean).join(' ').toLowerCase();
67
+ if (haystack.includes(raw)) return true;
68
+ if (effectiveTokens.length > 0) {
69
+ const matchCount = effectiveTokens.filter(term => haystack.includes(term)).length;
70
+ return matchCount > 0 && (matchCount / effectiveTokens.length >= 0.5);
71
+ }
72
+ return false;
73
+ };
74
+ }
43
75
 
44
76
  export class ToolCache {
45
77
  constructor(maxSize = 300, defaultTtlMs = 600_000) {
@@ -100,6 +132,17 @@ const WEB_CACHE_TOOLS = new Set([
100
132
  'fetch_json',
101
133
  ]);
102
134
 
135
+
136
+ const LIVE_DATA_TOOLS = new Set([
137
+ 'get_social_feed',
138
+ 'get_gallery_photos',
139
+ 'get_awards',
140
+ 'get_career_timeline',
141
+ 'get_brand_endorsements',
142
+ 'get_music_videos',
143
+ 'get_media_links',
144
+ ]);
145
+
103
146
  const CACHED_DISCOGRAPHY = (() => {
104
147
  const map = new Map();
105
148
  const allTracks = [
@@ -183,7 +226,7 @@ export const toolsSchema = [
183
226
  type: "function",
184
227
  function: {
185
228
  name: "search_works",
186
- description: "Search Ju Jingyi's filmography (TV dramas, web series, movies, and 2026/2025 upcoming projects) with official streaming links and YouTube trailer search links.",
229
+ description: "Search Ju Jingyi's filmography (TV dramas, web series, movies, and upcoming projects). Returns grounded role context, synopsis, highlights, catalog platform, and trailer search links; use these fields instead of inferring plot or acting claims.",
187
230
  parameters: {
188
231
  type: "object",
189
232
  properties: {
@@ -309,11 +352,11 @@ export const toolsSchema = [
309
352
  description: "Query awards and honors received by Ju Jingyi across music, television, and cultural influence.",
310
353
  parameters: {
311
354
  type: "object",
312
- properties: {
313
- category: { type: "string", description: "Category (Acting, Music, Honor)" },
314
- year: { type: "number", description: "Year filter" },
315
- query: { type: "string", description: "Award title, ceremony, or category keyword" },
316
- limit: { type: "number", description: "Maximum results from 1 to 20" }
355
+ properties: {
356
+ category: { type: "string", description: "Category (Acting, Music, Honor)" },
357
+ year: { type: "number", description: "Year filter" },
358
+ query: { type: "string", description: "Award title, ceremony, or category keyword" },
359
+ limit: { type: "number", description: "Maximum results from 1 to 20" }
317
360
  },
318
361
  required: []
319
362
  }
@@ -341,11 +384,11 @@ export const toolsSchema = [
341
384
  description: "Search visual photo gallery of Ju Jingyi (red carpet, photoshoot, drama stills).",
342
385
  parameters: {
343
386
  type: "object",
344
- properties: {
345
- category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
346
- query: { type: "string", description: "Photo title or category keyword" },
347
- year: { type: "number", description: "Year filter" },
348
- limit: { type: "number", description: "Number of photos from 1 to 20" }
387
+ properties: {
388
+ category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
389
+ query: { type: "string", description: "Photo title or category keyword" },
390
+ year: { type: "number", description: "Year filter" },
391
+ limit: { type: "number", description: "Number of photos from 1 to 20" }
349
392
  },
350
393
  required: []
351
394
  }
@@ -707,7 +750,8 @@ export const toolsSchema = [
707
750
  type: "object",
708
751
  properties: {
709
752
  topic: { type: "string", description: "Optional topic or category focus (e.g. 'drama', 'music', 'fashion', '2026')." },
710
- limit: { type: "number", description: "Number of suggestions to generate (default: 5)." }
753
+ limit: { type: "number", description: "Number of suggestions to generate (default: 5)." },
754
+ lang: { type: "string", description: "UI language for the questions: th, zh, en, ja, ko (default: en)." }
711
755
  }
712
756
  }
713
757
  }
@@ -764,6 +808,50 @@ export const toolsSchema = [
764
808
  }
765
809
  ];
766
810
 
811
+
812
+ const TOOL_DEFAULT_TIMEOUTS = {
813
+ run_subagent: 180_000,
814
+ run_deep_research: 300_000,
815
+ verify_claim: 300_000,
816
+ suggest_topics: 120_000,
817
+ web_extract: 90_000,
818
+ screenshot_webpage: 90_000,
819
+ search_youtube: 60_000,
820
+ play_music: 60_000,
821
+ };
822
+
823
+ async function ensureLlmAvailable(client) {
824
+ if (!client || typeof client.checkConnection !== 'function') return null;
825
+ try {
826
+ const status = await client.checkConnection();
827
+ if (status && status.connected === false) {
828
+ const provider = client.providerName || client.provider || 'LLM';
829
+ return {
830
+ status: 'error',
831
+ message: `AI provider offline (${provider}): ${status.error || 'connection failed'}. Start LM Studio or configure a cloud provider key, or answer from local catalog tools instead.`
832
+ };
833
+ }
834
+ } catch {
835
+
836
+ }
837
+ return null;
838
+ }
839
+
840
+ export const RUMORED_OR_EXTERNAL_PROJECTS = [
841
+ {
842
+ keywords: ['七星彩', 'qi xiang cai', 'qi xing cai', 'ชีซิงไฉ่', 'seven star color'],
843
+ titleCn: '七星彩',
844
+ titleEn: 'Qi Xing Cai (Seven Star Color)',
845
+ status: 'rumored_casting',
846
+ producer: 'Youku (优酷) x Stellar Media (恒星引力传媒)',
847
+ originalNovel: '《七星彩》 by 明月珰 (Mingyue Dang)',
848
+ leads: '纪澄 (Ji Cheng) & 沈彻 (Shen Che)',
849
+ details: 'โปรเจกต์ซีรีส์จีนโบราณฟอร์มยักษ์ของ Youku ร่วมกับค่ายเหิงซิงอิ่นลี่ (ผู้สร้างของรักของข้า Love Between Fairy and Devil) ดัดแปลงจากนิยายของหมิงเยว่ตัง เล่าเรื่องราวของจี้เฉิง บุตรสาวตระกูลพ่อค้าที่ใช้ปฏิภาณไหวพริบเอาตัวรอดจนได้พบกับแม่ทัพเสิ่นเช่อ',
850
+ buzzReason: 'เหตุผลที่ชาวเน็ตให้ความสนใจอย่างมากคือ กระแสข่าวลือแคสติ้งนักแสดงนำ (Casting Rumors / 选角瓜) โดยมีการลือชื่อนักแสดงหญิงแถวหน้าหลายคนรวมถึง จวีจิ้งอี, หลูอวี้เสี่ยว ฯลฯ แต่ปัจจุบันยังไม่มีการประกาศยืนยันนักแสดงทางการจากทางค่ายหรือสตูดิโอ',
851
+ disambiguation: 'คนละผลงานกับซีรีส์ 《千香》 (The Eternal Fragrance / พันกลิ่นบุปผา) ที่จวีจิ้งอีนำแสดงร่วมกับ ซ่งเวยหลง'
852
+ }
853
+ ];
854
+
767
855
  export const toolHandlers = {
768
856
  get_profile_and_fandom: async () => {
769
857
  const d = profileData.details || {};
@@ -786,6 +874,8 @@ export const toolHandlers = {
786
874
  hobbies: d.hobbies,
787
875
  favoriteFoods: d.favoriteFoods,
788
876
  talentsSummary: d.talentsSummary,
877
+ petsSummary: d.petsSummary,
878
+ cats: profileData.cats || [],
789
879
  fandomName: d.fandomName || "มี่จวี (Miju / 蜜橘)",
790
880
  fandomColor: d.fandomColorName || "ฟ้าคริสตัล & ชมพูซากุระ",
791
881
  slogan: profileData.motto || "保持热爱,奔赴山海",
@@ -822,24 +912,29 @@ export const toolHandlers = {
822
912
  if (year) results = results.filter(w => w.year === Number(year));
823
913
  if (category && category !== 'all') results = results.filter(w => w.category === category);
824
914
 
825
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'ศิลปิน', 'ดารา', 'งานล่าสุด', 'ผลงานล่าสุด', 'ล่าสุด', 'latest', 'newest', 'recent', 'upcoming', 'ละคร'];
826
- const isGenericArtistQuery = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
827
-
828
- if (query && !isGenericArtistQuery) {
829
- const q = query.toLowerCase().trim();
830
- results = results.filter(w =>
831
- (w.titleTh && w.titleTh.toLowerCase().includes(q)) ||
832
- (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
833
- (w.titleCn && w.titleCn.includes(query)) ||
834
- (w.role && w.role.toLowerCase().includes(q)) ||
835
- (w.roleCn && w.roleCn.includes(query))
836
- );
915
+ if (query) {
916
+ const matcher = createCatalogMatcher(query);
917
+ results = results.filter(w => matcher(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis));
837
918
  }
838
919
 
839
920
  if (featuredOnly && results.some(w => !!w.featured)) {
840
921
  results = results.filter(w => !!w.featured);
841
922
  }
842
923
 
924
+ if (results.length === 0 && query) {
925
+ const qLower = String(query).toLowerCase().trim();
926
+ const rumored = RUMORED_OR_EXTERNAL_PROJECTS.find(p => p.keywords.some(k => qLower.includes(k)));
927
+ if (rumored) {
928
+ return {
929
+ status: "rumored_project_info",
930
+ count: 0,
931
+ works: [],
932
+ rumoredProject: rumored,
933
+ message: `"${rumored.titleCn}" (${rumored.titleEn}) เป็นโปรเจกต์ข่าวลือแคสติ้ง/ยังไม่ยืนยัน: ${rumored.details}. จุดที่ชาวเน็ตสนใจ: ${rumored.buzzReason}. ข้อควรระวัง: ${rumored.disambiguation}.`
934
+ };
935
+ }
936
+ }
937
+
843
938
  results.sort((a, b) => (b.year || 0) - (a.year || 0));
844
939
 
845
940
  return {
@@ -855,17 +950,21 @@ export const toolHandlers = {
855
950
  titleEn: w.titleEn,
856
951
  year: w.year,
857
952
  category: w.category,
858
- role: w.role,
859
- roleCn: w.roleCn,
953
+ role: w.role,
954
+ roleCn: w.roleCn,
955
+ synopsis: w.synopsis || null,
956
+ highlights: (w.highlights || []).slice(0, 3),
860
957
  episodes: w.episodes,
861
958
  rating: w.rating,
862
959
  platform: w.streamPlatform,
863
960
  streamUrl: w.streamUrl || null,
864
961
  youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
962
+ youtubeTrailerIsDirectLink: false,
865
963
  youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
866
964
  themeSong: w.themeSong ? {
867
- title: formatCatalogTitle(w.themeSong.titleTh, w.themeSong.titleCn),
965
+ title: formatCatalogTitle(w.themeSong.titleTh, w.themeSong.titleCn),
868
966
  youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
967
+ isDirectLink: Boolean(themeSongYtId),
869
968
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
870
969
  } : null
871
970
  };
@@ -876,12 +975,20 @@ export const toolHandlers = {
876
975
  get_work_detail: async ({ workIdOrTitle }) => {
877
976
  if (!workIdOrTitle) return { status: "error", message: "workIdOrTitle parameter is required." };
878
977
  const q = workIdOrTitle.toLowerCase().trim();
879
- const work = worksData.find(w =>
880
- w.id.toLowerCase() === q ||
978
+
979
+ const normId = (value) => String(value || '').toLowerCase().trim().replace(/[_\s]+/g, '-');
980
+ const qId = normId(workIdOrTitle);
981
+ let work = worksData.find(w =>
982
+ normId(w.id) === qId ||
881
983
  w.titleTh.toLowerCase().includes(q) ||
882
984
  (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
883
985
  (w.titleCn && w.titleCn.includes(workIdOrTitle))
884
986
  );
987
+ if (!work) {
988
+
989
+ const matcher = createCatalogMatcher(workIdOrTitle);
990
+ work = worksData.find(w => matcher(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn));
991
+ }
885
992
 
886
993
  if (!work) {
887
994
  return { status: "not_found", message: `No work found for "${workIdOrTitle}".` };
@@ -899,19 +1006,22 @@ export const toolHandlers = {
899
1006
  titleEn: work.titleEn,
900
1007
  year: work.year,
901
1008
  category: work.category,
902
- role: work.role,
1009
+ role: work.role,
1010
+ roleCn: work.roleCn,
903
1011
  episodes: work.episodes,
904
1012
  rating: work.rating,
905
1013
  platform: work.streamPlatform,
906
1014
  streamUrl: work.streamUrl || null,
907
1015
  youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
1016
+ youtubeTrailerIsDirectLink: false,
908
1017
  youkuSearchUrl: buildYoukuSearchUrl(work.titleCn || work.titleEn),
909
1018
  bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${work.titleCn}`),
910
- synopsis: work.synopsis?.slice(0, 250) + (work.synopsis?.length > 250 ? '...' : ''),
1019
+ synopsis: work.synopsis || null,
911
1020
  highlights: (work.highlights || []).slice(0, 3),
912
1021
  themeSong: work.themeSong ? {
913
- title: formatCatalogTitle(work.themeSong.titleTh, work.themeSong.titleCn),
1022
+ title: formatCatalogTitle(work.themeSong.titleTh, work.themeSong.titleCn),
914
1023
  youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
1024
+ isDirectLink: Boolean(themeSongYtId),
915
1025
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${work.themeSong.titleCn || work.themeSong.titleTh} OST`)
916
1026
  } : null
917
1027
  }
@@ -920,7 +1030,7 @@ export const toolHandlers = {
920
1030
 
921
1031
  get_character_roles: async ({ query } = {}) => {
922
1032
  const roles = worksData.map(w => ({
923
- workTitle: formatCatalogTitle(w.titleTh, w.titleCn),
1033
+ workTitle: formatCatalogTitle(w.titleTh, w.titleCn),
924
1034
  year: w.year,
925
1035
  role: w.role,
926
1036
  roleCn: w.roleCn,
@@ -928,16 +1038,9 @@ export const toolHandlers = {
928
1038
  }));
929
1039
 
930
1040
  let filtered = roles;
931
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu'];
932
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
933
-
934
- if (query && !isGeneric) {
935
- const q = query.toLowerCase().trim();
936
- filtered = roles.filter(r =>
937
- (r.role && r.role.toLowerCase().includes(q)) ||
938
- (r.roleCn && r.roleCn.includes(query)) ||
939
- (r.workTitle && r.workTitle.toLowerCase().includes(q))
940
- );
1041
+ if (query) {
1042
+ const matcher = createCatalogMatcher(query);
1043
+ filtered = roles.filter(r => matcher(r.role, r.roleCn, r.workTitle));
941
1044
  }
942
1045
 
943
1046
  filtered.sort((a, b) => (b.year || 0) - (a.year || 0));
@@ -956,17 +1059,19 @@ export const toolHandlers = {
956
1059
  if (type === 'ost') list = list.filter(s => s.work && s.work !== "Solo Single");
957
1060
  if (type === 'single') list = list.filter(s => !s.work || s.work === "Solo Single");
958
1061
 
959
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'เพลง', 'เพลงทั้งหมด', 'เพลงใหม่', 'music', 'song', 'songs'];
960
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
961
-
962
- if (query && !isGeneric) {
963
- const q = query.toLowerCase().trim();
964
- list = list.filter(s =>
965
- (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
966
- (s.titleCn && s.titleCn.includes(query)) ||
967
- (s.titleEn && s.titleEn.toLowerCase().includes(q)) ||
968
- (s.work && s.work.toLowerCase().includes(q))
969
- );
1062
+ if (query) {
1063
+ const qLower = String(query).toLowerCase().trim();
1064
+ const rumored = RUMORED_OR_EXTERNAL_PROJECTS.find(p => p.keywords.some(k => qLower.includes(k)));
1065
+ if (rumored) {
1066
+ return {
1067
+ status: "not_found",
1068
+ count: 0,
1069
+ songs: [],
1070
+ message: `"${rumored.titleCn}" (${rumored.titleEn}) เป็นโปรเจกต์ซีรีส์ที่มีข่าวลือแคสติ้ง ไม่ใช่เพลงหรือ OST ของจวีจิ้งอีในคลังผลงาน และคนละเรื่องกับเพลง 《千香》 (The Eternal Fragrance).`
1071
+ };
1072
+ }
1073
+ const matcher = createCatalogMatcher(query);
1074
+ list = list.filter(s => matcher(s.titleTh, s.titleCn, s.titleEn, s.work, s.role, s.description));
970
1075
  }
971
1076
 
972
1077
  return {
@@ -975,12 +1080,13 @@ export const toolHandlers = {
975
1080
  count: list.length,
976
1081
  songs: list.slice(0, 8).map(s => ({
977
1082
  id: s.id,
978
- title: formatCatalogTitle(s.titleTh, s.titleCn),
1083
+ title: formatCatalogTitle(s.titleTh, s.titleCn),
979
1084
  titleEn: s.titleEn,
980
1085
  year: s.year,
981
1086
  type: s.work,
982
1087
  duration: s.duration,
983
1088
  youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
1089
+ isDirectLink: Boolean(s.youtubeUrl || s.youtubeId),
984
1090
  youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
985
1091
  qqMusicSearchUrl: s.qqMusicSearchUrl || buildQqMusicSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`),
986
1092
  bilibiliSearchUrl: s.bilibiliSearchUrl || buildBilibiliSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`)
@@ -1004,18 +1110,9 @@ export const toolHandlers = {
1004
1110
  });
1005
1111
  }
1006
1112
 
1007
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'mv', 'music video', 'วิดีโอ', 'คลิป'];
1008
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
1009
-
1010
- if (query && !isGeneric) {
1011
- const q = query.toLowerCase().trim();
1012
- results = results.filter(m =>
1013
- (m.titleTh && m.titleTh.toLowerCase().includes(q)) ||
1014
- (m.titleCn && m.titleCn.includes(query)) ||
1015
- (m.titlePinyin && m.titlePinyin.toLowerCase().includes(q)) ||
1016
- (m.dramaName && m.dramaName.toLowerCase().includes(q)) ||
1017
- (m.description && m.description.toLowerCase().includes(q))
1018
- );
1113
+ if (query) {
1114
+ const matcher = createCatalogMatcher(query);
1115
+ results = results.filter(m => matcher(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description));
1019
1116
  }
1020
1117
 
1021
1118
  return {
@@ -1026,7 +1123,7 @@ export const toolHandlers = {
1026
1123
  const searchTitle = `Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage Live' : 'Official MV'}`;
1027
1124
  return {
1028
1125
  id: m.id,
1029
- title: formatCatalogTitle(m.titleTh, m.titleCn),
1126
+ title: formatCatalogTitle(m.titleTh, m.titleCn),
1030
1127
  category: m.category,
1031
1128
  year: m.releaseYear || m.year,
1032
1129
  drama: m.dramaName || null,
@@ -1034,6 +1131,7 @@ export const toolHandlers = {
1034
1131
  description: m.description || null,
1035
1132
  isFeatured: !!m.isFeatured,
1036
1133
  youtubeUrl: ytId ? buildYouTubeWatchUrl(ytId) : null,
1134
+ isDirectLink: Boolean(ytId),
1037
1135
  youtubeSearchUrl: buildYouTubeSearchUrl(searchTitle),
1038
1136
  bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
1039
1137
  };
@@ -1056,7 +1154,7 @@ export const toolHandlers = {
1056
1154
  (m.dramaName && m.dramaName.toLowerCase().includes(q))
1057
1155
  ).map(m => ({
1058
1156
  type: m.category || 'MV',
1059
- title: formatCatalogTitle(m.titleTh, m.titleCn),
1157
+ title: formatCatalogTitle(m.titleTh, m.titleCn),
1060
1158
  year: m.releaseYear || m.year,
1061
1159
  duration: m.duration,
1062
1160
  youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
@@ -1071,7 +1169,7 @@ export const toolHandlers = {
1071
1169
  (s.work && s.work.toLowerCase().includes(q))
1072
1170
  ).map(s => ({
1073
1171
  type: s.work || 'Song',
1074
- title: formatCatalogTitle(s.titleTh, s.titleCn),
1172
+ title: formatCatalogTitle(s.titleTh, s.titleCn),
1075
1173
  year: s.year,
1076
1174
  duration: s.duration,
1077
1175
  youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
@@ -1086,14 +1184,14 @@ export const toolHandlers = {
1086
1184
  (w.titleEn && w.titleEn.toLowerCase().includes(q))
1087
1185
  ).map(w => ({
1088
1186
  type: 'Drama / Series',
1089
- title: formatCatalogTitle(w.titleTh, w.titleCn),
1187
+ title: formatCatalogTitle(w.titleTh, w.titleCn),
1090
1188
  year: w.year,
1091
1189
  platform: w.streamPlatform,
1092
1190
  streamUrl: w.streamUrl || null,
1093
1191
  youtubeTrailerSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer Official`),
1094
1192
  youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
1095
1193
  themeSong: w.themeSong ? {
1096
- title: formatCatalogTitle(w.themeSong.titleTh, w.themeSong.titleCn),
1194
+ title: formatCatalogTitle(w.themeSong.titleTh, w.themeSong.titleCn),
1097
1195
  youtubeUrl: w.themeSong.youtubeId ? buildYouTubeWatchUrl(w.themeSong.youtubeId) : null,
1098
1196
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
1099
1197
  } : null
@@ -1122,24 +1220,13 @@ export const toolHandlers = {
1122
1220
  if (year) list = list.filter(b => b.year === Number(year));
1123
1221
  if (category) list = list.filter(b => (b.category && b.category.toLowerCase().includes(category.toLowerCase())) || (b.tier && b.tier.toLowerCase().includes(category.toLowerCase())));
1124
1222
 
1125
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'แบรนด์', 'พรีเซนเตอร์', 'brand', 'ambassador', 'endorsement', 'commercial'];
1126
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
1127
-
1128
- if (query && !isGeneric) {
1129
- const q = query.toLowerCase().trim();
1130
- const numYear = Number(q);
1223
+ if (query) {
1224
+ const numYear = Number(String(query).trim());
1131
1225
  if (!isNaN(numYear) && numYear >= 2013 && numYear <= 2030) {
1132
1226
  list = list.filter(b => b.year === numYear);
1133
1227
  } else {
1134
- list = list.filter(b =>
1135
- (b.brandNameEn && b.brandNameEn.toLowerCase().includes(q)) ||
1136
- (b.brandNameCn && b.brandNameCn.includes(query)) ||
1137
- (b.title && b.title.toLowerCase().includes(q)) ||
1138
- (b.category && b.category.toLowerCase().includes(q)) ||
1139
- (b.highlight && b.highlight.toLowerCase().includes(q)) ||
1140
- (b.tier && b.tier.toLowerCase().includes(q)) ||
1141
- (b.name && b.name.toLowerCase().includes(q))
1142
- );
1228
+ const matcher = createCatalogMatcher(query);
1229
+ list = list.filter(b => matcher(b.brandNameEn, b.brandNameCn, b.name, b.title, b.category, b.highlight, b.tier));
1143
1230
  }
1144
1231
  }
1145
1232
 
@@ -1170,39 +1257,61 @@ export const toolHandlers = {
1170
1257
  count: eras.length,
1171
1258
  milestones: eras.slice(0, 6).map(e => ({
1172
1259
  period: e.period || e.year,
1173
- title: formatCatalogTitle(e.title, e.titleCn),
1260
+ title: formatCatalogTitle(e.title, e.titleCn),
1174
1261
  summary: e.summary?.slice(0, 150),
1175
1262
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${e.titleCn || e.title}`)
1176
1263
  }))
1177
1264
  };
1178
1265
  },
1179
1266
 
1180
- get_awards: async ({ category, year, query, limit = 8 } = {}) => {
1181
- let list = Array.isArray(awardsData) ? awardsData : (awardsData.awards || []);
1182
- if (year) list = list.filter(a => a.year === Number(year));
1183
- if (category && category !== 'all') list = list.filter(a => a.category?.toLowerCase().includes(category.toLowerCase()));
1184
- if (query) {
1185
- const q = String(query).toLowerCase().trim();
1186
- list = list.filter(a => [a.titleTh, a.titleCn, a.titleEn, a.ceremony, a.category].filter(Boolean).join(' ').toLowerCase().includes(q));
1187
- }
1188
- const safeLimit = Math.min(Math.max(Number(limit) || 8, 1), 20);
1189
-
1190
- return {
1191
- status: "success",
1192
- count: list.length,
1193
- awards: list.slice(0, safeLimit).map((a, index) => ({
1194
- id: a.id || `award-${a.year || 'unknown'}-${index + 1}`,
1195
- year: a.year,
1196
- title: `${a.titleTh || a.titleEn || a.titleCn || 'Award'}${a.titleCn && a.titleCn !== a.titleTh ? ` (${a.titleCn})` : ''}`,
1197
- ceremony: a.ceremony,
1198
- category: a.category,
1199
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${a.titleCn || a.ceremony}`)
1200
- }))
1267
+ get_awards: async ({ category, year, query, limit = 8 } = {}) => {
1268
+ let list = Array.isArray(awardsData) ? awardsData : (awardsData.awards || []);
1269
+ if (year) list = list.filter(a => a.year === Number(year));
1270
+ if (category && category !== 'all') list = list.filter(a => a.category?.toLowerCase().includes(category.toLowerCase()));
1271
+
1272
+ let usedQueryFilter = false;
1273
+ if (query) {
1274
+ const q = String(query).toLowerCase().trim();
1275
+
1276
+ const generic = /(ju jingyi|jujingyi|จวีจิ้งอี|จิ้งอี|จวี|鞠婧祎|小鞠|kiku|kikuuu|ju jing yi|蜜橘)/i.test(q);
1277
+ if (!generic) {
1278
+ const filtered = list.filter(a => [a.titleTh, a.titleCn, a.titleEn, a.ceremony, a.category, a.work].filter(Boolean).join(' ').toLowerCase().includes(q));
1279
+ if (filtered.length > 0) {
1280
+ list = filtered;
1281
+ usedQueryFilter = true;
1282
+ }
1283
+
1284
+ }
1285
+ }
1286
+
1287
+ list.sort((a, b) => (b.year || 0) - (a.year || 0));
1288
+ const safeLimit = Math.min(Math.max(Number(limit) || 8, 1), 20);
1289
+ const awards = list.slice(0, safeLimit).map((a, index) => ({
1290
+ id: a.id || `award-${a.year || 'unknown'}-${index + 1}`,
1291
+ year: a.year,
1292
+ title: `${a.titleTh || a.titleEn || a.titleCn || 'Award'}${a.titleCn && a.titleCn !== a.titleTh ? ` (${a.titleCn})` : ''}`,
1293
+ ceremony: a.ceremony,
1294
+ category: a.category,
1295
+ work: a.work || null,
1296
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${a.titleCn || a.ceremony}`)
1297
+ }));
1298
+
1299
+ const result = {
1300
+ status: awards.length > 0 ? "success" : "not_found",
1301
+ count: awards.length,
1302
+ awards
1201
1303
  };
1304
+ if (usedQueryFilter === false && query) {
1305
+ result.message = `Showing all ${awards.length} awards (broad query " ${query} " applied to full list).`;
1306
+ }
1307
+ return result;
1202
1308
  },
1203
1309
 
1204
1310
  get_social_feed: async ({ platform, limit = 4 } = {}) => {
1205
- let posts = socialUpdatesData.cards || [];
1311
+
1312
+ const douyinCards = socialUpdatesData?.douyin?.cards || [];
1313
+ const weiboCards = socialUpdatesData?.weibo?.cards || [];
1314
+ let posts = [...douyinCards, ...weiboCards];
1206
1315
  if (platform && platform !== 'all') posts = posts.filter(p => p.platform?.toLowerCase() === platform.toLowerCase());
1207
1316
 
1208
1317
  return {
@@ -1211,46 +1320,55 @@ export const toolHandlers = {
1211
1320
  posts: posts.slice(0, Number(limit) || 4).map(p => ({
1212
1321
  platform: p.platform,
1213
1322
  date: p.date,
1214
- content: p.content?.slice(0, 120) + '...',
1323
+ content: String(p.content || p.captionTh || p.captionCn || p.title || p.titleCn || '').slice(0, 120) + '...',
1215
1324
  likes: p.likes
1216
1325
  }))
1217
1326
  };
1218
1327
  },
1219
1328
 
1220
- get_gallery_photos: async ({ category, query, year, limit = 4 } = {}) => {
1221
- let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
1222
- if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1223
- if (query) {
1224
- const q = String(query).toLowerCase().trim();
1225
- photos = photos.filter(p => [p.titleTh, p.titleCn, p.titleEn, p.category, p.description].filter(Boolean).join(' ').toLowerCase().includes(q));
1226
- }
1227
- if (year) photos = photos.filter(p => Number(p.era || p.year) === Number(year));
1228
- const safeLimit = Math.min(Math.max(Number(limit) || 4, 1), 20);
1229
-
1230
- return {
1231
- status: "success",
1232
- count: Math.min(photos.length, safeLimit),
1233
- photos: photos.slice(0, safeLimit).map((p, index) => ({
1234
- id: p.id || `photo-${p.era || p.year || 'unknown'}-${index + 1}`,
1235
- title: p.titleTh || p.titleCn,
1329
+ get_gallery_photos: async ({ category, query, year, limit = 4 } = {}) => {
1330
+ let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
1331
+ if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1332
+ let usedQueryFilter = false;
1333
+ if (query) {
1334
+ const q = String(query).toLowerCase().trim();
1335
+
1336
+ const generic = /(ju jingyi|jujingyi|จวีจิ้งอี|จิ้งอี|จวี|鞠婧祎|小鞠|kiku|kikuuu|ju jing yi|蜜橘)/i.test(q);
1337
+ if (!generic) {
1338
+ const filtered = photos.filter(p => [p.titleTh, p.titleCn, p.titleEn, p.category, p.description].filter(Boolean).join(' ').toLowerCase().includes(q));
1339
+ if (filtered.length > 0) {
1340
+ photos = filtered;
1341
+ usedQueryFilter = true;
1342
+ }
1343
+
1344
+ }
1345
+ }
1346
+ if (year) photos = photos.filter(p => Number(p.era || p.year) === Number(year));
1347
+ const safeLimit = Math.min(Math.max(Number(limit) || 4, 1), 20);
1348
+ const shown = photos.slice(0, safeLimit);
1349
+
1350
+ const result = {
1351
+ status: "success",
1352
+ count: Math.min(photos.length, safeLimit),
1353
+ photos: shown.map((p, index) => ({
1354
+ id: p.id || `photo-${p.era || p.year || 'unknown'}-${index + 1}`,
1355
+ title: p.titleTh || p.titleCn,
1236
1356
  category: p.category,
1237
1357
  year: p.era || p.year
1238
1358
  }))
1239
1359
  };
1360
+ if (usedQueryFilter === false && query) {
1361
+ result.message = `Showing all ${result.count} photos (broad query "${query}" applied to full list).`;
1362
+ }
1363
+ return result;
1240
1364
  },
1241
1365
 
1242
1366
  global_search: async ({ query }) => {
1243
1367
  if (!query) return { status: "error", message: "Query parameter is required." };
1244
- const q = query.toLowerCase();
1245
- const ignoredTerms = new Set(['the', 'from', 'what', 'are', 'drama', 'ost', 'song', 'songs', 'soundtrack', 'music']);
1246
- const terms = q.split(/[^\p{L}\p{N}]+/u).filter(term => term.length > 2 && !ignoredTerms.has(term));
1247
- const matchesQuery = (...values) => {
1248
- const haystack = values.filter(Boolean).join(' ').toLowerCase();
1249
- return haystack.includes(q) || (terms.length > 0 && terms.every(term => haystack.includes(term)));
1250
- };
1368
+ const matcher = createCatalogMatcher(query);
1251
1369
 
1252
1370
  const matchedWorks = worksData.filter(w =>
1253
- matchesQuery(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn)
1371
+ matcher(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn)
1254
1372
  ).slice(0, 4).map(w => ({
1255
1373
  title: `${w.titleTh} (${w.year}) - Role: ${w.role}`,
1256
1374
  trailerYouTubeSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer`)
@@ -1258,24 +1376,26 @@ export const toolHandlers = {
1258
1376
 
1259
1377
  const disc = getCleanDiscography();
1260
1378
  const matchedSongs = disc.filter(s =>
1261
- matchesQuery(s.titleTh, s.titleEn, s.titleCn, s.work, s.role)
1379
+ matcher(s.titleTh, s.titleEn, s.titleCn, s.work, s.role, s.description)
1262
1380
  ).slice(0, 4).map(s => ({
1263
1381
  title: `${s.titleTh} (${s.year})`,
1264
1382
  youtubeUrl: s.youtubeUrl || null,
1383
+ isDirectLink: Boolean(s.youtubeUrl),
1265
1384
  youtubeSearchUrl: s.youtubeSearchUrl
1266
1385
  }));
1267
1386
 
1268
1387
  const mvs = getAllMusicVideos().filter(m =>
1269
- matchesQuery(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description)
1388
+ matcher(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description)
1270
1389
  ).slice(0, 4).map(m => ({
1271
- title: `${formatCatalogTitle(m.titleTh, m.titleCn)} [${m.category}]`,
1390
+ title: `${formatCatalogTitle(m.titleTh, m.titleCn)} [${m.category}]`,
1272
1391
  youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1392
+ isDirectLink: Boolean(m.youtubeId),
1273
1393
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh}`)
1274
1394
  }));
1275
1395
 
1276
- const brands = Array.isArray(brandsData) ? (brandsData.brandList || []) : (brandsData.brands || []);
1396
+ const brands = Array.isArray(brandsData) ? brandsData : (brandsData.brandList || brandsData.brands || []);
1277
1397
  const matchedBrands = brands.filter(b =>
1278
- matchesQuery(b.brandNameEn, b.brandNameCn, b.name, b.title)
1398
+ matcher(b.brandNameEn, b.brandNameCn, b.name, b.title, b.category, b.highlight)
1279
1399
  ).slice(0, 4).map(b => `${b.brandNameEn || b.name} (${b.year}) - ${b.title}`);
1280
1400
 
1281
1401
  const count = matchedWorks.length + matchedSongs.length + mvs.length + matchedBrands.length;
@@ -1302,32 +1422,35 @@ export const toolHandlers = {
1302
1422
  return await fetchWebpage(url);
1303
1423
  },
1304
1424
 
1305
- fetch_api: async ({ endpoint, query, baseUrl } = {}) => {
1306
- if (!endpoint) return { status: "error", message: "endpoint parameter is required (e.g. 'profile', 'works', 'search')" };
1307
- const base = (baseUrl || 'https://jujingyi-kikuuu.vercel.app/api/v1').replace(/\/$/, '');
1308
- const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
1309
- let url = `${base}${path}`;
1310
- if (query) url += `?q=${encodeURIComponent(query)}`;
1311
-
1312
- try {
1313
- const controller = new AbortController();
1314
- const timeoutId = setTimeout(() => controller.abort(), 10000);
1315
- const res = await fetch(url, {
1316
- headers: { 'Accept': 'application/json', 'User-Agent': 'KikuAgent/1.0' },
1317
- signal: controller.signal
1318
- });
1319
- clearTimeout(timeoutId);
1320
-
1321
- if (!res.ok) {
1322
- return { status: "error", message: `HTTP ${res.status} from ${url}` };
1323
- }
1324
-
1325
- const json = await res.json();
1326
-
1327
- const data = json.data || json;
1328
- return {
1329
- status: "success",
1330
- endpoint: path,
1425
+ fetch_api: async ({ endpoint, query, baseUrl } = {}) => {
1426
+ if (!endpoint) return { status: "error", message: "endpoint parameter is required (e.g. 'profile', 'works', 'search')" };
1427
+ const base = (baseUrl || 'https://jujingyi-kikuuu.vercel.app/api/v1').replace(/\/$/, '');
1428
+ const originCheck = sanitizeUrl(base);
1429
+ if (!originCheck.safe) {
1430
+ return { status: "error", message: `Unsafe API base URL: ${originCheck.reason}` };
1431
+ }
1432
+ const allowedOrigins = new Set([
1433
+ 'https://jujingyi-kikuuu.vercel.app',
1434
+ ...(process.env.KIKU_API_ALLOWED_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean)
1435
+ ]);
1436
+ if (!originCheck.url || !allowedOrigins.has(originCheck.url.origin)) {
1437
+ return { status: 'error', message: 'API base origin is not allowlisted.' };
1438
+ }
1439
+ const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
1440
+ const target = new URL(`${base}/${endpoint.replace(/^\/+/, '')}`);
1441
+ if (target.origin !== originCheck.url.origin || !target.pathname.startsWith('/api/v1/')) {
1442
+ return { status: 'error', message: 'API endpoint must remain under /api/v1/.' };
1443
+ }
1444
+ if (query) target.searchParams.set('q', query);
1445
+
1446
+ try {
1447
+ const result = await fetchJson(target.href, { timeoutMs: 10000, maxResponseLength: 100000 });
1448
+ if (result.status !== 'success') return result;
1449
+ const json = result.data;
1450
+ const data = json?.data || json;
1451
+ return {
1452
+ status: "success",
1453
+ endpoint: path,
1331
1454
  count: Array.isArray(data) ? data.length : undefined,
1332
1455
  data
1333
1456
  };
@@ -1335,7 +1458,7 @@ export const toolHandlers = {
1335
1458
  return {
1336
1459
  status: "error",
1337
1460
  endpoint: path,
1338
- message: err.name === 'AbortError' ? 'API request timed out' : err.message
1461
+ message: err.name === 'AbortError' ? 'API request timed out' : err.message
1339
1462
  };
1340
1463
  }
1341
1464
  },
@@ -1347,15 +1470,21 @@ export const toolHandlers = {
1347
1470
  read_local_file: async ({ path: filePath, maxLines = 200 } = {}) => {
1348
1471
  if (!filePath) return { status: 'error', message: 'path parameter is required.' };
1349
1472
  try {
1350
- const projectRoot = process.cwd();
1351
- const resolved = resolve(projectRoot, filePath);
1352
- const relativePath = relative(projectRoot, resolved);
1353
-
1354
- if (relativePath.startsWith('..') || relativePath.includes('..')) {
1355
- return { status: 'error', message: 'Path must be within the project directory.' };
1356
- }
1357
-
1358
- const fileStat = await stat(resolved);
1473
+ const projectRoot = await realpath(process.cwd());
1474
+ const resolved = resolve(projectRoot, filePath);
1475
+ const realResolved = await realpath(resolved);
1476
+ const relativePath = relative(projectRoot, realResolved);
1477
+
1478
+ if (relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
1479
+ return { status: 'error', message: 'Path must be within the project directory.' };
1480
+ }
1481
+
1482
+ if (/(^|[\\/])\.env(?:\.|$)/i.test(relativePath) || /(^|[\\/])cookies\.txt$/i.test(relativePath) ||
1483
+ /\.(pem|key|p12|pfx)$/i.test(relativePath) || /(^|[\\/])\.(git|npmrc|netrc|ssh)([\\/]|$)/i.test(relativePath)) {
1484
+ return { status: 'error', message: 'Reading this file is not permitted (sensitive file).' };
1485
+ }
1486
+
1487
+ const fileStat = await stat(realResolved);
1359
1488
  if (!fileStat.isFile()) {
1360
1489
  return { status: 'error', message: `"${filePath}" is not a file.` };
1361
1490
  }
@@ -1364,7 +1493,7 @@ export const toolHandlers = {
1364
1493
  return { status: 'error', message: `File too large (${Math.round(fileStat.size / 1024)}KB). Use maxLines to read a portion.` };
1365
1494
  }
1366
1495
 
1367
- const content = await readFile(resolved, 'utf-8');
1496
+ const content = await readFile(realResolved, 'utf-8');
1368
1497
  const lines = content.split('\n');
1369
1498
  const totalLines = lines.length;
1370
1499
  const truncated = totalLines > maxLines;
@@ -1389,13 +1518,14 @@ export const toolHandlers = {
1389
1518
 
1390
1519
  list_directory: async ({ path: dirPath = '.', recursive = false } = {}) => {
1391
1520
  try {
1392
- const projectRoot = process.cwd();
1393
- const resolved = resolve(projectRoot, dirPath);
1394
- const relativePath = relative(projectRoot, resolved);
1395
-
1396
- if (relativePath.startsWith('..') || relativePath.includes('..')) {
1397
- return { status: 'error', message: 'Path must be within the project directory.' };
1398
- }
1521
+ const projectRoot = await realpath(process.cwd());
1522
+ const resolved = resolve(projectRoot, dirPath);
1523
+ const realResolved = await realpath(resolved);
1524
+ const relativePath = relative(projectRoot, realResolved);
1525
+
1526
+ if (relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
1527
+ return { status: 'error', message: 'Path must be within the project directory.' };
1528
+ }
1399
1529
 
1400
1530
  async function listDir(dir, depth = 0) {
1401
1531
  if (depth > (recursive ? 3 : 0)) return [];
@@ -1435,7 +1565,7 @@ export const toolHandlers = {
1435
1565
  return items;
1436
1566
  }
1437
1567
 
1438
- const items = await listDir(resolved);
1568
+ const items = await listDir(realResolved);
1439
1569
  return {
1440
1570
  status: 'success',
1441
1571
  path: relativePath || '.',
@@ -1466,8 +1596,20 @@ export const toolHandlers = {
1466
1596
  const mvs = getAllMusicVideos();
1467
1597
  const gpu = await getGpuMetrics().catch(() => ({ available: false }));
1468
1598
 
1599
+ const debutMatch = profileData.details?.debutDate?.match(/\b(19\d\d|20\d\d)\b/);
1600
+ const debutYear = debutMatch ? Number(debutMatch[1]) : 2013;
1601
+ const years = worksData.map(w => w.year).filter(y => typeof y === 'number' && !isNaN(y));
1602
+ const maxYear = years.length > 0 ? Math.max(...years, new Date().getFullYear()) : new Date().getFullYear();
1603
+ const activeYears = `${debutYear} - Present (${maxYear})`;
1604
+ const latestWorks = worksData.filter(w => w.year === maxYear);
1605
+ const featuredWorks = latestWorks.length > 0
1606
+ ? latestWorks.map(w => formatCatalogTitle(w.titleTh, w.titleCn))
1607
+ : ["Veil of Shadows (月鳞绮纪)", "The Eternal Fragrance (千香)", "Wanhua Sin (万华世界)"];
1608
+
1469
1609
  return {
1470
1610
  status: "success",
1611
+ sourceType: "local_database",
1612
+ dataFreshness: "2026-09-02",
1471
1613
  stats: {
1472
1614
  artistName: profileData.nameTh,
1473
1615
  artistNameCn: profileData.nameCn,
@@ -1477,8 +1619,8 @@ export const toolHandlers = {
1477
1619
  brandEndorsements: brands.length,
1478
1620
  totalAwards: awards.length,
1479
1621
  timelineMilestones: timeline.length,
1480
- activeYears: "2013 - Present (2026)",
1481
- featured2026Works: ["Veil of Shadows (月鳞绮纪)", "The Eternal Fragrance (千香)", "Wanhua Sin (万华世界)"],
1622
+ activeYears,
1623
+ featured2026Works: featuredWorks,
1482
1624
  youtubeOfficialHub: buildYouTubeSearchUrl("Ju Jingyi 鞠婧祎"),
1483
1625
  gpu: gpu.available ? gpu.primary : { available: false }
1484
1626
  }
@@ -1540,7 +1682,12 @@ export const toolHandlers = {
1540
1682
  },
1541
1683
 
1542
1684
  remove_from_queue: async (args) => {
1543
- const result = await audioPlayer.removeFromQueue(args.index);
1685
+ const queue = audioPlayer.getQueue();
1686
+ const index = Number(args.index);
1687
+ if (!Number.isInteger(index) || index < 0 || index >= queue.length) {
1688
+ return { status: 'error', message: `Invalid queue index ${args.index} (queue has ${queue.length} item(s)).` };
1689
+ }
1690
+ const result = await audioPlayer.removeFromQueue(index);
1544
1691
  return { status: 'success', message: result };
1545
1692
  },
1546
1693
 
@@ -1574,54 +1721,100 @@ export const toolHandlers = {
1574
1721
  },
1575
1722
 
1576
1723
  run_subagent: async ({ task }, options = {}) => {
1724
+ const forwardProgress = (event) => options?.onSubagentProgress?.({ source: 'subagent', task, ...event });
1725
+ forwardProgress({ type: 'start' });
1577
1726
  if (options?.agent?.runSubagent) {
1578
- const result = await options.agent.runSubagent(task, { signal: options.signal });
1727
+ const result = await options.agent.runSubagent(task, { signal: options.signal, onProgress: forwardProgress });
1579
1728
  return { status: "success", task, answer: result.answer, toolCalls: result.toolCalls, iterations: result.iterations };
1580
1729
  }
1581
1730
  const { LMStudioClient } = await import('./llmClient.js');
1582
1731
  const { ResearchSubagent } = await import('./subagent.js');
1583
1732
  const client = options?.client || new LMStudioClient();
1733
+ const offline = await ensureLlmAvailable(client);
1734
+ if (offline) return { ...offline, task };
1584
1735
  const subagent = new ResearchSubagent({ client });
1585
- const result = await subagent.run(task, { signal: options?.signal });
1736
+ const result = await subagent.run(task, { signal: options?.signal, onProgress: forwardProgress });
1586
1737
  return { status: "success", task, answer: result.answer, toolCalls: result.toolCalls, iterations: result.iterations };
1587
1738
  },
1588
1739
 
1589
1740
  run_deep_research: async ({ question }, options = {}) => {
1590
- if (options?.agent?.runResearch) {
1591
- const result = await options.agent.runResearch(question, { signal: options.signal });
1741
+ const forwardProgress = (event) => options?.onSubagentProgress?.({ source: 'research', question, ...event });
1742
+ forwardProgress({ type: 'start' });
1743
+ try {
1744
+ if (options?.agent?.runResearch) {
1745
+ const result = await options.agent.runResearch(question, { signal: options.signal, onProgress: forwardProgress });
1746
+ return { status: "success", question, answer: result.answer, sources: result.sources, reports: result.reports };
1747
+ }
1748
+ const { LMStudioClient } = await import('./llmClient.js');
1749
+ const { ResearchOrchestrator } = await import('./research.js');
1750
+ const client = options?.client || options?.agent?.client || new LMStudioClient();
1751
+ const offlineResearch = await ensureLlmAvailable(client);
1752
+ if (offlineResearch) throw new Error(offlineResearch.message);
1753
+ const orchestrator = options?.agent?.research || new ResearchOrchestrator({ client });
1754
+ const result = await orchestrator.run(question, { signal: options?.signal, onProgress: forwardProgress });
1592
1755
  return { status: "success", question, answer: result.answer, sources: result.sources, reports: result.reports };
1756
+ } catch (err) {
1757
+ try {
1758
+ const { webExtract } = await import('./webSearch.js');
1759
+ const extractRes = await webExtract(question);
1760
+ return {
1761
+ status: "success",
1762
+ question,
1763
+ answer: extractRes.text || extractRes.summary || "Retrieved live web intelligence.",
1764
+ sources: extractRes.sources || [],
1765
+ fallbackUsed: true
1766
+ };
1767
+ } catch {
1768
+ return { status: "error", question, message: err.message || "Deep research failed." };
1769
+ }
1593
1770
  }
1594
- const { LMStudioClient } = await import('./llmClient.js');
1595
- const { ResearchOrchestrator } = await import('./research.js');
1596
- const client = options?.client || new LMStudioClient();
1597
- const orchestrator = new ResearchOrchestrator({ client });
1598
- const result = await orchestrator.run(question, { signal: options?.signal });
1599
- return { status: "success", question, answer: result.answer, sources: result.sources, reports: result.reports };
1600
1771
  },
1601
1772
 
1602
1773
  verify_claim: async ({ claim }, options = {}) => {
1603
- if (options?.agent?.runResearch) {
1604
- const result = await options.agent.runResearch(claim, { mode: 'verify', signal: options.signal });
1774
+ const forwardProgress = (event) => options?.onSubagentProgress?.({ source: 'verify', claim, ...event });
1775
+ forwardProgress({ type: 'start' });
1776
+ try {
1777
+ if (options?.agent?.runResearch) {
1778
+ const result = await options.agent.runResearch(claim, { mode: 'verify', signal: options.signal, onProgress: forwardProgress });
1779
+ return { status: "success", claim, answer: result.answer, sources: result.sources, reports: result.reports };
1780
+ }
1781
+ const { LMStudioClient } = await import('./llmClient.js');
1782
+ const { ResearchOrchestrator } = await import('./research.js');
1783
+ const client = options?.client || options?.agent?.client || new LMStudioClient();
1784
+ const offlineVerify = await ensureLlmAvailable(client);
1785
+ if (offlineVerify) throw new Error(offlineVerify.message);
1786
+ const orchestrator = options?.agent?.research || new ResearchOrchestrator({ client });
1787
+ const result = await orchestrator.run(claim, { mode: 'verify', signal: options?.signal, onProgress: forwardProgress });
1605
1788
  return { status: "success", claim, answer: result.answer, sources: result.sources, reports: result.reports };
1789
+ } catch (err) {
1790
+ try {
1791
+ const { webExtract } = await import('./webSearch.js');
1792
+ const extractRes = await webExtract(claim);
1793
+ return {
1794
+ status: "success",
1795
+ claim,
1796
+ answer: extractRes.text || extractRes.summary || "Retrieved live web verification intelligence.",
1797
+ sources: extractRes.sources || [],
1798
+ fallbackUsed: true
1799
+ };
1800
+ } catch {
1801
+ return { status: "error", claim, message: err.message || "Verification failed." };
1802
+ }
1606
1803
  }
1607
- const { LMStudioClient } = await import('./llmClient.js');
1608
- const { ResearchOrchestrator } = await import('./research.js');
1609
- const client = options?.client || new LMStudioClient();
1610
- const orchestrator = new ResearchOrchestrator({ client });
1611
- const result = await orchestrator.run(claim, { mode: 'verify', signal: options?.signal });
1612
- return { status: "success", claim, answer: result.answer, sources: result.sources, reports: result.reports };
1613
1804
  },
1614
1805
 
1615
- suggest_topics: async ({ topic, limit }, options = {}) => {
1806
+ suggest_topics: async ({ topic, limit, lang }, options = {}) => {
1616
1807
  if (options?.agent?.generateSuggestions) {
1617
- const suggestions = await options.agent.generateSuggestions({ topic, limit, signal: options.signal });
1808
+ const suggestions = await options.agent.generateSuggestions({ topic, limit, lang, signal: options.signal });
1618
1809
  return { status: "success", topic, suggestions };
1619
1810
  }
1620
1811
  const { SuggestionEngine } = await import('./suggestions.js');
1621
1812
  const { LMStudioClient } = await import('./llmClient.js');
1622
1813
  const client = options?.client || new LMStudioClient();
1814
+ const offline = await ensureLlmAvailable(client);
1815
+ if (offline) return { ...offline, topic };
1623
1816
  const engine = new SuggestionEngine({ client });
1624
- const suggestions = await engine.generateSuggestions({ topic, limit, signal: options?.signal });
1817
+ const suggestions = await engine.generateSuggestions({ topic, limit, lang, signal: options?.signal });
1625
1818
  return { status: "success", topic, suggestions };
1626
1819
  },
1627
1820
 
@@ -1839,6 +2032,10 @@ export function normalizeToolResult(toolName, result) {
1839
2032
  return normalized;
1840
2033
  }
1841
2034
 
2035
+ export function isBuiltinTool(toolName) {
2036
+ return Boolean(toolHandlers && toolHandlers[toolName]);
2037
+ }
2038
+
1842
2039
  export async function executeTool(toolName, args = {}, options = {}) {
1843
2040
  const handler = toolHandlers[toolName];
1844
2041
  if (!handler) {
@@ -1860,14 +2057,16 @@ export async function executeTool(toolName, args = {}, options = {}) {
1860
2057
 
1861
2058
  const isStatic = STATIC_CATALOG_TOOLS.has(toolName);
1862
2059
  const isWeb = WEB_CACHE_TOOLS.has(toolName);
1863
- const cacheKey = (isStatic || isWeb) ? `${toolName}:${JSON.stringify(prepared.args)}` : null;
2060
+ const isLive = LIVE_DATA_TOOLS.has(toolName);
2061
+ const cacheable = (isStatic || isWeb) && !isLive;
2062
+ const cacheKey = cacheable ? `${toolName}:${JSON.stringify(prepared.args)}` : null;
1864
2063
 
1865
2064
  if (cacheKey && !options.noCache) {
1866
2065
  const cached = globalToolCache.get(cacheKey);
1867
2066
  if (cached) return cached;
1868
2067
  }
1869
2068
 
1870
- const configuredTimeout = Number(options.timeoutMs ?? process.env.KIKU_TOOL_TIMEOUT_MS ?? 45_000);
2069
+ const configuredTimeout = Number(options.timeoutMs ?? TOOL_DEFAULT_TIMEOUTS[toolName] ?? process.env.KIKU_TOOL_TIMEOUT_MS ?? 45_000);
1871
2070
  const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 45_000;
1872
2071
  let timeoutId;
1873
2072
  const timeoutPromise = new Promise((_, reject) => {
@@ -1885,8 +2084,8 @@ export async function executeTool(toolName, args = {}, options = {}) {
1885
2084
  normalized.validationWarnings = prepared.warnings;
1886
2085
  }
1887
2086
 
1888
- if (cacheKey && normalized.status !== 'error') {
1889
- const ttl = isStatic ? 3600_000 : 900_000;
2087
+ if (cacheKey && normalized.status === 'success' && (normalized.count === undefined || normalized.count > 0)) {
2088
+ const ttl = isStatic ? 600_000 : 300_000;
1890
2089
  globalToolCache.set(cacheKey, normalized, ttl);
1891
2090
  }
1892
2091