kiku-agent 1.2.0 → 1.3.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 (100) hide show
  1. package/README.md +459 -407
  2. package/package.json +27 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +629 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +528 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/lanDiscovery.js +369 -0
  23. package/server/agent/llmClient.js +111 -39
  24. package/server/agent/lmStudioWakeup.js +144 -0
  25. package/server/agent/mcpClient.js +2 -2
  26. package/server/agent/memory.js +635 -494
  27. package/server/agent/modelConfig.js +487 -0
  28. package/server/agent/newsIntelligence.js +219 -0
  29. package/server/agent/newsWatch.js +85 -0
  30. package/server/agent/prompts.js +146 -0
  31. package/server/agent/publicTools.js +7 -0
  32. package/server/agent/queryDetails.js +62 -1
  33. package/server/agent/research.js +314 -35
  34. package/server/agent/routingInterceptor.js +77 -0
  35. package/server/agent/rumoredProjects.js +15 -0
  36. package/server/agent/sanitizer.js +117 -17
  37. package/server/agent/subagent.js +52 -9
  38. package/server/agent/suggestionTui.js +33 -13
  39. package/server/agent/suggestions.js +29 -13
  40. package/server/agent/telemetry.js +0 -11
  41. package/server/agent/terminalImage.js +0 -1
  42. package/server/agent/textToolProtocol.js +190 -0
  43. package/server/agent/tokenTracker.js +220 -0
  44. package/server/agent/toolRouter.js +362 -216
  45. package/server/agent/tools.js +1155 -194
  46. package/server/agent/ui.js +144 -41
  47. package/server/agent/vramService.js +572 -0
  48. package/server/agent/webSearch.js +2279 -273
  49. package/server/agent/youtubeAuth.js +82 -73
  50. package/server/agent/youtubeTranscript.js +209 -0
  51. package/server/apiRouter.js +383 -179
  52. package/server/mcp/data/brands.js +5 -5
  53. package/server/mcp/data/discography.js +56 -45
  54. package/server/mcp/data/gallery.js +9 -0
  55. package/server/mcp/data/index.js +1 -0
  56. package/server/mcp/data/industryIndex.js +251 -0
  57. package/server/mcp/data/profile.js +5 -5
  58. package/server/mcp/data/timeline.js +2 -2
  59. package/server/mcp/data/works.js +2 -2
  60. package/server/mcp/handler.js +0 -2
  61. package/server/mcp/prompts.js +42 -2
  62. package/server/mcp/resources.js +5 -1
  63. package/server/mcp/security.js +25 -14
  64. package/server/mcp/server.js +92 -3
  65. package/server/mcp/tools.js +20 -11
  66. package/server/web-chat/core/connection.js +218 -194
  67. package/server/web-chat/core/conversationContext.js +1 -1
  68. package/server/web-chat/core/directCatalogEngine.js +190 -184
  69. package/server/web-chat/core/followUpEngine.js +88 -11
  70. package/server/web-chat/core/security.js +7 -4
  71. package/server/web-chat/router.js +54 -17
  72. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  73. package/server/web-chat/routes/chatRoute.js +488 -397
  74. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  75. package/server/web-chat/routes/exportRoute.js +2 -1
  76. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  77. package/server/web-chat/routes/newsRoute.js +36 -6
  78. package/server/web-chat/routes/providerRoute.js +87 -0
  79. package/server/web-chat/routes/staticRoute.js +61 -13
  80. package/server/web-chat/routes/statusRoute.js +23 -2
  81. package/server/web-chat/routes/vramRoute.js +22 -0
  82. package/server/web-chat/server.js +135 -106
  83. package/server/web-chat/ui/htmlBuilder.js +8 -8
  84. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  85. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  86. package/server/web-chat/ui/scripts/client.js +10526 -8397
  87. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  88. package/server/web-chat/ui/styles/main.css +7471 -4246
  89. package/server/web-chat/ui/templates/body.html +305 -179
  90. package/src/data/brandsData.js +29 -6
  91. package/src/data/discographyData.js +918 -900
  92. package/src/data/galleryData.js +66 -0
  93. package/src/data/industryIndexData.js +292 -0
  94. package/src/data/musicVideoData.js +48 -5
  95. package/src/data/profileData.js +15 -1
  96. package/src/data/timelineData.js +4 -3
  97. package/src/data/worksData.js +536 -535
  98. package/src/lib/vram/estimator.js +464 -0
  99. package/src/lib/vram/modelRegistry.js +387 -0
  100. package/tokens.css +33 -2
@@ -7,12 +7,20 @@ import { socialUpdatesData } from '../../src/data/socialUpdatesData.js';
7
7
  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
- import { searchWeb, fetchWebpage, fetchJson, screenshotWebpage, webExtract } from './webSearch.js';
11
- import { readFile, readdir, stat, realpath } from 'node:fs/promises';
10
+ import { getDynamicEntries, addDynamicEntry } from './dynamicCatalog.js';
11
+ import { industryIndexData, platformsCatalog, artistIndustryMetrics, categoryDefinitions } from '../../src/data/industryIndexData.js';
12
+ import {
13
+ searchWeb, fetchWebpage, fetchJson, screenshotWebpage, webExtract,
14
+ searchNews, fetchRssFeed, searchEncyclopedia, searchMusicPlatforms,
15
+ searchChineseWeb, searchBilibili, searchWebImages, detectLanguage
16
+ } from './webSearch.js';
17
+ import { readFile, readdir, stat, realpath } from 'node:fs/promises';
12
18
  import { join, relative, extname, resolve } from 'node:path';
13
19
  import { audioPlayer } from './audioPlayer.js';
20
+ import { fetchYoutubeTranscript } from './youtubeTranscript.js';
14
21
  import { sanitizeToolArgs, sanitizeUrl } from './sanitizer.js';
15
22
  import { getGpuMetrics } from './gpuMetrics.js';
23
+ import { levenshteinDistance, levenshteinSimilarity, levenshteinScore, CATEGORY_ALIASES } from './catalogEntityResolver.js';
16
24
 
17
25
  export function buildYouTubeSearchUrl(query) {
18
26
  return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
@@ -41,25 +49,46 @@ export function formatCatalogTitle(primary, secondary) {
41
49
  return `${first} (${second})`;
42
50
  }
43
51
 
52
+ export const CATALOG_STOP_WORDS = new Set([
53
+ 'the', 'from', 'what', 'are', 'is', 'about', 'drama', 'series', 'ost', 'song', 'songs', 'soundtrack', 'music', 'single', 'album', 'stage',
54
+ 'brand', 'brands', 'ambassador', 'endorsement', 'commercial', 'role', 'roles', 'character', 'characters', 'award', 'awards', 'category', 'video', 'mv', 'clip',
55
+ 'ju', 'jingyi', 'jujingyi', 'kiku', 'kikuuu', 'จวีจิ้งอี', 'จวี', 'จิ้งอี', 'เสี่ยวจวี', 'เสี่ยว', '鞠婧祎', '小鞠', '鞠',
56
+ 'เพลง', 'เพลงของ', 'ซีรีส์', 'ซีรีส์ของ', 'ละคร', 'เรื่อง', 'ผลงาน', 'อัลบั้ม', 'คอนเสิร์ต', 'บทบาท', 'ตัวละคร', 'แบรนด์', 'รางวัล', 'ผลงานล่าสุด', 'พรีเซนเตอร์',
57
+ 'ค้นหา', 'หา', 'ดู', 'เปิด', 'ฟัง', 'โปรโมท', 'โปรโมต', 'การโปรโมท', 'การโปรโมต', 'โฆษณา', 'แคมเปญ', 'ข้อมูล', 'รายละเอียด', 'บน', 'ใน', 'ที่', 'กับ', 'ช่วย', 'อยาก', 'ให้', 'หน่อย', 'คือ', 'อะไร', 'บ้าง', 'ไหม', 'มั้ย', 'มี',
58
+ 'search', 'find', 'show', 'look', 'promo', 'promote', 'promoted', 'campaign', 'for', 'with', 'tell', 'me', 'please', 'can', 'you', 'info', 'details',
59
+ '搜索', '查找', '看看', '播放', '在', '上的', '关于', '有什么', '吗', '呢',
60
+ 'bilibili', 'b站', 'youtube', 'weibo', 'douyin', 'spotify', 'qqmusic', 'iqiyi', 'youku', 'tencent'
61
+ ]);
62
+
63
+ const PERSONAL_FACT_KEYWORDS = Object.freeze([
64
+ 'birthplace', 'born', 'birthday', 'education', 'instrument', 'violin', 'blood', 'height', 'hometown', 'cat', 'cats', 'pet', 'pets', 'fandom', 'slogan', 'motto', 'กำเนิด', 'เกิด', 'บ้านเกิด', 'ไวโอลิน', 'เครื่องดนตรี', 'การศึกษา', 'กรุ๊ปเลือด', 'ส่วนสูง', 'แมว', '出生', '籍贯', '血型', '身高', '毕业'
65
+ ]);
66
+
67
+ function normalizeWorkId(value) {
68
+ return String(value || '').toLowerCase().trim().replace(/[_\s]+/g, '-');
69
+ }
70
+
71
+ const WORKS_BY_NORM_ID = new Map();
72
+ for (const w of worksData) {
73
+ const id = normalizeWorkId(w.id);
74
+ if (id && !WORKS_BY_NORM_ID.has(id)) WORKS_BY_NORM_ID.set(id, w);
75
+ }
76
+
44
77
  export function createCatalogMatcher(query) {
45
78
  if (!query) return () => true;
46
79
  const raw = String(query).toLowerCase().trim();
47
80
  if (!raw) return () => true;
48
81
 
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
82
  const allTokens = raw.split(/[^\p{L}\p{M}\p{N}]+/u).filter(t => t.length >= 2);
57
-
58
83
  const deDated = raw.replace(/ใหม่ล่าสุด|ล่าสุด|最新|latest|newest|recent/gi, ' ');
59
84
  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;
85
+ const coreTokens = datedTokens.filter(t => !CATALOG_STOP_WORDS.has(t));
86
+
87
+ if (coreTokens.length === 0) {
88
+ const broadMatcher = () => true;
89
+ broadMatcher.broad = true;
90
+ return broadMatcher;
91
+ }
63
92
  const effectiveTokens = coreTokens;
64
93
 
65
94
  return (...fields) => {
@@ -67,7 +96,27 @@ export function createCatalogMatcher(query) {
67
96
  if (haystack.includes(raw)) return true;
68
97
  if (effectiveTokens.length > 0) {
69
98
  const matchCount = effectiveTokens.filter(term => haystack.includes(term)).length;
70
- return matchCount > 0 && (matchCount / effectiveTokens.length >= 0.5);
99
+ if (matchCount > 0 && (matchCount / effectiveTokens.length >= 0.5)) return true;
100
+ }
101
+
102
+ if (raw.length >= 4) {
103
+ for (const field of fields) {
104
+ if (!field) continue;
105
+ const fStr = String(field).toLowerCase();
106
+ if (fStr.includes(raw) || raw.includes(fStr)) return true;
107
+ const words = fStr.split(/\s+/);
108
+ for (let i = 0; i < words.length; i++) {
109
+ for (let len = 1; len <= Math.min(5, words.length - i); len++) {
110
+ const sub = words.slice(i, i + len).join(' ');
111
+ if (sub.length >= 4 && Math.abs(sub.length - raw.length) <= 3) {
112
+ const { dist, sim } = levenshteinScore(raw, sub);
113
+ if (dist <= 2 || sim >= 0.75) {
114
+ return true;
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
71
120
  }
72
121
  return false;
73
122
  };
@@ -114,15 +163,17 @@ const STATIC_CATALOG_TOOLS = new Set([
114
163
  'get_profile_and_fandom',
115
164
  'get_quotes',
116
165
  'get_awards',
117
- 'get_timeline',
166
+ 'get_career_timeline',
118
167
  'get_brand_endorsements',
119
- 'get_gallery',
168
+ 'get_gallery_photos',
120
169
  'get_music_videos',
121
170
  'search_works',
122
171
  'search_discography',
123
172
  'get_work_detail',
124
- 'get_social_updates',
125
173
  'get_media_links',
174
+ 'get_entertainment_metrics',
175
+ 'get_industry_index',
176
+ 'global_search',
126
177
  ]);
127
178
 
128
179
  const WEB_CACHE_TOOLS = new Set([
@@ -130,17 +181,19 @@ const WEB_CACHE_TOOLS = new Set([
130
181
  'web_extract',
131
182
  'fetch_webpage',
132
183
  'fetch_json',
184
+ 'search_news',
185
+ 'fetch_rss',
186
+ 'search_encyclopedia',
187
+ 'search_music_platforms',
188
+ 'search_chinese_web',
189
+ 'search_bilibili',
190
+ 'search_web_images',
133
191
  ]);
134
192
 
135
193
 
194
+
136
195
  const LIVE_DATA_TOOLS = new Set([
137
196
  '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
197
  ]);
145
198
 
146
199
  const CACHED_DISCOGRAPHY = (() => {
@@ -163,6 +216,11 @@ const CACHED_DISCOGRAPHY = (() => {
163
216
  titleEn: track.titleEn || '',
164
217
  year: track.year,
165
218
  work: track.work || "Solo Single",
219
+ category: track.category || 'single',
220
+ categoryName: track.categoryName || '',
221
+ role: track.role || '',
222
+ description: track.description || '',
223
+ badge: track.badge || '',
166
224
  duration: track.duration || track.trackDuration || '',
167
225
  theme: track.theme || "Pop / Ballad",
168
226
  youtubeId: ytId,
@@ -171,6 +229,12 @@ const CACHED_DISCOGRAPHY = (() => {
171
229
  qqMusicSearchUrl: buildQqMusicSearchUrl(`鞠婧祎 ${track.titleCn || track.titleTh}`),
172
230
  bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${track.titleCn || track.titleTh}`)
173
231
  });
232
+ } else {
233
+ const existing = map.get(key);
234
+ if (!existing.role && track.role) existing.role = track.role;
235
+ if (!existing.description && track.description) existing.description = track.description;
236
+ if (!existing.categoryName && track.categoryName) existing.categoryName = track.categoryName;
237
+ if (!existing.work && track.work) existing.work = track.work;
174
238
  }
175
239
  }
176
240
 
@@ -195,12 +259,33 @@ function getAllMusicVideos() {
195
259
  return CACHED_MUSIC_VIDEOS;
196
260
  }
197
261
 
262
+
263
+ const WORK_CATEGORY_VALUES = ['historical-drama', 'modern-drama', 'movie'];
264
+ const WORK_CATEGORY_SYNONYMS = {
265
+ series: ['historical-drama', 'modern-drama'],
266
+ drama: ['historical-drama', 'modern-drama'],
267
+ tv: ['historical-drama', 'modern-drama'],
268
+ television: ['historical-drama', 'modern-drama'],
269
+ film: ['movie'],
270
+ cinema: ['movie'],
271
+ };
272
+
273
+ export function normalizeWorkCategoryFilter(category) {
274
+ const raw = String(category || '').toLowerCase().trim();
275
+ if (!raw || raw === 'all') return null;
276
+ const compact = raw.replace(/[\s_]+/g, '-');
277
+ if (WORK_CATEGORY_VALUES.includes(compact)) return [compact];
278
+ if (WORK_CATEGORY_SYNONYMS[compact]) return WORK_CATEGORY_SYNONYMS[compact];
279
+ const partial = WORK_CATEGORY_VALUES.filter(k => k.includes(compact) || compact.includes(k));
280
+ return partial.length ? partial : [compact];
281
+ }
282
+
198
283
  export const toolsSchema = [
199
284
  {
200
285
  type: "function",
201
286
  function: {
202
- name: "get_profile_and_fandom",
203
- description: "Get Ju Jingyi's biographical profile, Chinese/English/Thai names, birthday, birthplace, Miju (蜜橘) fan community, official slogan, and socials.",
287
+ name: "get_current_datetime",
288
+ description: "Get the current local date and time (ISO 8601, timezone, weekday, and current year). Call this whenever 'today', 'now', or the current year matters — NEVER guess or rely on training data for dates.",
204
289
  parameters: {
205
290
  type: "object",
206
291
  properties: {},
@@ -208,15 +293,60 @@ export const toolsSchema = [
208
293
  }
209
294
  }
210
295
  },
296
+ {
297
+ type: "function",
298
+ function: {
299
+ name: "get_youtube_transcript",
300
+ description: "Fetch the caption/transcript text of a YouTube video (manual or auto captions). Use it to verify lyrics, quotes, or statements from interviews instead of guessing their content.",
301
+ parameters: {
302
+ type: "object",
303
+ properties: {
304
+ url: { type: "string", description: "Full YouTube video URL (watch, youtu.be, shorts, embed, or live)" },
305
+ videoId: { type: "string", description: "11-character YouTube video ID — alternative to url" },
306
+ lang: { type: "string", enum: ["th", "en", "zh"], description: "Preferred caption language" },
307
+ maxChars: { type: "number", description: "Maximum transcript characters to return (default 6000, cap 20000)" }
308
+ },
309
+ required: []
310
+ }
311
+ }
312
+ },
313
+ {
314
+ type: "function",
315
+ function: {
316
+ name: "get_profile_and_fandom",
317
+ description: "Get Ju Jingyi's biographical profile — the authoritative source for ALL personal facts: Chinese/English/Thai names, birthday, birthplace, height, blood type, education, musical instruments (classical violin), debut date, SNH48 graduation date, current status, Miju (蜜橘) fan community, official slogan, pets, and socials. For exact personal dates and biographical facts use this instead of get_career_timeline. Optional sections expose talents, career stats, Forbes-level honors, and official social links.",
318
+ parameters: {
319
+ type: "object",
320
+ properties: {
321
+ query: {
322
+ type: "string",
323
+ description: "Optional fact keyword to inspect (e.g. 'birthplace', 'education', 'instrument', 'violin', 'blood type', 'height', 'birthday', 'hometown', 'cats')"
324
+ },
325
+ section: {
326
+ type: "string",
327
+ enum: ["all", "talents", "stats", "honors", "social"],
328
+ description: "Optional extra profile section to include (default: all = core profile)"
329
+ }
330
+ },
331
+ required: []
332
+ }
333
+ }
334
+ },
211
335
  {
212
336
  type: "function",
213
337
  function: {
214
338
  name: "get_quotes",
215
- description: "Retrieve inspiring quotes and famous statements made by Ju Jingyi (with Thai translation and Chinese text).",
339
+ description: `Retrieve official quotes, mottoes, interview philosophies, mindsets, and life credos (e.g., "保持热爱,奔赴山海", "往前看").
340
+ [CRITICAL]: Use this tool when the user asks for her motto (座右铭), personal quotes, or philosophical sayings. DO NOT use get_profile_and_fandom for mindset/quotes.`,
216
341
  parameters: {
217
342
  type: "object",
218
343
  properties: {
219
- tag: { type: "string", description: "Optional tag (mindset, growth, acting, fandom)" }
344
+ tag: {
345
+ type: "string",
346
+ enum: ["mindset", "career", "fans", "all", "growth", "acting"],
347
+ description: "Theme tag of the quote"
348
+ },
349
+ query: { type: "string", description: "Specific keyword in Thai, Chinese, or English" }
220
350
  },
221
351
  required: []
222
352
  }
@@ -226,14 +356,14 @@ export const toolsSchema = [
226
356
  type: "function",
227
357
  function: {
228
358
  name: "search_works",
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.",
359
+ description: `Search filmography, drama series, movies, and character costume details (e.g., Sword and Fairy 4 / 仙剑四, In Blossom, Han Lingsha red costume).
360
+ [CRITICAL]: Use this tool for drama episode counts, platforms, character visual traits, and plot synopses. DO NOT use global_search.`,
230
361
  parameters: {
231
362
  type: "object",
232
363
  properties: {
233
- query: { type: "string", description: "Search keyword for drama title or character name." },
234
- year: { type: "number", description: "Specific release year (e.g. 2026, 2025, 2024, 2023). Leave empty to get newest." },
235
- category: { type: "string", description: "Category filter (e.g. 'series', 'movie', 'upcoming')." },
236
- featuredOnly: { type: "boolean", description: "Filter only featured major hit dramas." }
364
+ query: { type: "string", description: "Drama/Movie title or character name" },
365
+ year: { type: "number", description: "Release year" },
366
+ category: { type: "string", description: "Drama or movie category filter ('historical-drama', 'modern-drama', 'movie', 'series', 'drama', 'film', or 'all')" }
237
367
  },
238
368
  required: []
239
369
  }
@@ -257,11 +387,11 @@ export const toolsSchema = [
257
387
  type: "function",
258
388
  function: {
259
389
  name: "get_character_roles",
260
- description: "List and search famous fictional character roles portrayed by Ju Jingyi in dramas and movies (e.g. Yang Caiwei, Lu Wuyi, Bai Suzhen, Han Yunxi).",
390
+ description: "Authoritative role catalog for FICTIONAL drama and movie characters Ju Jingyi portrays (e.g. Yang Caiwei, Lu Wuyi, Bai Suzhen, Han Yunxi). NEVER call this for Ju Jingyi's real-life personal facts, birthplace, birthday, education, or blood type — use get_profile_and_fandom instead.",
261
391
  parameters: {
262
392
  type: "object",
263
393
  properties: {
264
- query: { type: "string", description: "Optional role name or drama title keyword" }
394
+ query: { type: "string", description: "Optional fictional role name or drama title keyword" }
265
395
  },
266
396
  required: []
267
397
  }
@@ -271,7 +401,8 @@ export const toolsSchema = [
271
401
  type: "function",
272
402
  function: {
273
403
  name: "search_discography",
274
- description: "Search Ju Jingyi's songs, singles, and OST drama soundtracks. Returns verified direct YouTube watch links (https://www.youtube.com/watch?v=...), real YouTube search queries (https://www.youtube.com/results?search_query=...), and music streaming links.",
404
+ description: `Search songs, solo singles, drama OSTs, and album tracks by Chinese title, English title, or Thai transliteration (e.g., View From The Wall / 壁上观, FIND THE WAY, 如纱, 不苦).
405
+ [CRITICAL]: Use this tool to find translations of song titles (Chinese <-> English). DO NOT use global_search for song queries.`,
275
406
  parameters: {
276
407
  type: "object",
277
408
  properties: {
@@ -287,7 +418,7 @@ export const toolsSchema = [
287
418
  type: "function",
288
419
  function: {
289
420
  name: "get_music_videos",
290
- description: "Retrieve music videos (MV), stage performances, and dance practices with verified direct YouTube links and YouTube search query URLs.",
421
+ description: "Use for official music videos, dance practices, video release years, and video durations. Titles may include alphanumeric codes or short tokens like '0.2s' or 'Everyday' or catalog video IDs. If a query asks for the stored releaseYear of a video entry (e.g. Dewdrops video ID), retrieve it here.",
291
422
  parameters: {
292
423
  type: "object",
293
424
  properties: {
@@ -302,7 +433,7 @@ export const toolsSchema = [
302
433
  type: "function",
303
434
  function: {
304
435
  name: "get_media_links",
305
- description: "Get direct YouTube video watch links (watch?v=...), official YouTube search queries (results?search_query=...), Bilibili links, and streaming URLs for any Ju Jingyi song, MV, live stage, or drama trailer.",
436
+ description: "LINKS ONLY: get direct YouTube video watch links (watch?v=...), official YouTube search queries (results?search_query=...), Bilibili links, and streaming URLs for a given Ju Jingyi song, MV, live stage, or drama trailer. It returns media URLs and basic metadata but NO character roles, song credits, or duet artists — for role facts use search_works/get_character_roles, for song facts use search_discography. Use this tool only when the user asks for URLs/links themselves.",
306
437
  parameters: {
307
438
  type: "object",
308
439
  properties: {
@@ -317,7 +448,8 @@ export const toolsSchema = [
317
448
  type: "function",
318
449
  function: {
319
450
  name: "get_brand_endorsements",
320
- description: "Retrieve brand endorsements, commercial ambassadorships, luxury fashion, and beauty partnerships of Ju Jingyi.",
451
+ description: `Authoritative source for all commercial brand contracts, spokesperson titles, global ambassadors, and commercial sales records/statistics (e.g., FRED Paris high jewelry, PRSR Milan, Lafang 30-year ambassador, TuTuCare 20,000 gift boxes 1-second sellout, DAFUYAN).
452
+ [CRITICAL]: If query mentions a commercial brand, ambassador title, or sales record/statistics, ALWAYS call this tool. Do NOT answer from memory or claim lack of records without querying.`,
321
453
  parameters: {
322
454
  type: "object",
323
455
  properties: {
@@ -334,12 +466,14 @@ export const toolsSchema = [
334
466
  type: "function",
335
467
  function: {
336
468
  name: "get_career_timeline",
337
- description: "Explore Ju Jingyi's career milestones and chronological eras (SNH48 Era, Solo Studio, Breakthrough Dramas, Present).",
469
+ description: `Use for historical chronology, career milestones, debut dates, annual awards, election rankings, and chronological milestones across 2013-2026.
470
+ [CRITICAL]: Whenever the user query asks 'from timeline', 'according to timeline' ('ตามไทม์ไลน์', '按时间线', 'in the timeline') or asks for chronological events in a specific year (e.g. 2015 Seine River/Running Man China, 2016 election champion & center song Princess's Cloak, 2017 May 4th Medal & Studio graduation, 2018 co-star with Mike in Mr. Swimmer, 2019 Forbes 30 Under 30 Asia, 2024 Beauty Ambassador), you MUST invoke get_career_timeline. Do NOT substitute with search_works or get_media_links.`,
338
471
  parameters: {
339
472
  type: "object",
340
473
  properties: {
341
474
  year: { type: "number", description: "Year filter (2013-2026)" },
342
- era: { type: "string", description: "Era keyword" }
475
+ era: { type: "string", description: "Era keyword (e.g. SNH48 Era, Solo Studio, Breakthrough Dramas, Present)" },
476
+ limit: { type: "number", description: "Maximum milestones to return (1-20)" }
343
477
  },
344
478
  required: []
345
479
  }
@@ -349,13 +483,18 @@ export const toolsSchema = [
349
483
  type: "function",
350
484
  function: {
351
485
  name: "get_awards",
352
- description: "Query awards and honors received by Ju Jingyi across music, television, and cultural influence.",
486
+ description: `Retrieve official acting and music awards, nominations, rankings, and festival honors (e.g., ERC Chinese Top Ten Awards / 东方风云榜, Weibo TV & Video Summit, Scream Night, Weibo Night).
487
+ [CRITICAL]: Use this tool for ANY question asking which song, role, or album WON an award or was honored at a music chart ceremony. DO NOT use get_media_links or search_discography for award verification.`,
353
488
  parameters: {
354
489
  type: "object",
355
490
  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" },
491
+ category: {
492
+ type: "string",
493
+ enum: ["Music", "Acting", "Breakthrough", "Fashion", "All"],
494
+ description: "Category of the award"
495
+ },
496
+ year: { type: "number", description: "Year of the award ceremony" },
497
+ query: { type: "string", description: "Ceremony name, song title, or drama title (e.g., 东方风云榜, 恋爱告急, In Blossom)" },
359
498
  limit: { type: "number", description: "Maximum results from 1 to 20" }
360
499
  },
361
500
  required: []
@@ -381,12 +520,13 @@ export const toolsSchema = [
381
520
  type: "function",
382
521
  function: {
383
522
  name: "get_gallery_photos",
384
- description: "Search visual photo gallery of Ju Jingyi (red carpet, photoshoot, drama stills).",
523
+ description: `Retrieve magazine editorial photoshoots, fashion pictorials, desert/studio spreads, and runway styling (e.g., PORT Magazine / 《PORT》九月刊, Harper's BAZAAR, Milan Fashion Week outfits).
524
+ [CRITICAL]: Use this tool when queries ask about clothing, colors, jewelry (e.g., FRED Force 10 in PORT photoshoot), or visual styling featured IN A MAGAZINE OR PHOTO SET. DO NOT call get_brand_endorsements unless the query specifically asks for commercial brand ambassadorship contracts.`,
385
525
  parameters: {
386
526
  type: "object",
387
527
  properties: {
388
528
  category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
389
- query: { type: "string", description: "Photo title or category keyword" },
529
+ query: { type: "string", description: "Magazine name, theme, jewelry collection, or outfit color (e.g., PORT, FRED, Milan, pink)" },
390
530
  year: { type: "number", description: "Year filter" },
391
531
  limit: { type: "number", description: "Number of photos from 1 to 20" }
392
532
  },
@@ -398,11 +538,14 @@ export const toolsSchema = [
398
538
  type: "function",
399
539
  function: {
400
540
  name: "global_search",
401
- description: "Universal search across ALL Ju Jingyi entities (works, songs, roles, brands, awards, videos) with YouTube and media links.",
541
+ description: `STRICT FALLBACK TOOL ONLY.
542
+ [NEGATIVE CONSTRAINT]: FORBIDDEN to use on the first turn or for specific entity queries (songs, works, awards, photos, endorsements).
543
+ [PERMITTED ONLY WHEN]: Domain-specific tools (search_works, search_discography, get_awards, etc.) return 0 results or count == 0.
544
+ Searches raw text catalog across all unindexed cross-domain documents.`,
402
545
  parameters: {
403
546
  type: "object",
404
547
  properties: {
405
- query: { type: "string", description: "Search term to match across all collections" }
548
+ query: { type: "string", description: "The exact fallback search string" }
406
549
  },
407
550
  required: ["query"]
408
551
  }
@@ -531,6 +674,113 @@ export const toolsSchema = [
531
674
  }
532
675
  }
533
676
  },
677
+ {
678
+ type: "function",
679
+ function: {
680
+ name: "search_news",
681
+ description: "Search live news via Google News RSS + Bing News RSS (supports Thai, Chinese, English automatically). PREFERRED over web_search whenever the user asks about news, latest updates, recent events, or what is new - results include publish dates. Append 'when:7d' to the query to narrow to the last N days.",
682
+ parameters: {
683
+ type: "object",
684
+ properties: {
685
+ query: { type: "string", description: "News search query, e.g. '鞠婧祎 最新' or 'Ju Jingyi news'" },
686
+ maxResults: { type: "number", description: "Max results (default 8, max 15)" }
687
+ },
688
+ required: ["query"]
689
+ }
690
+ }
691
+ },
692
+ {
693
+ type: "function",
694
+ function: {
695
+ name: "fetch_rss",
696
+ description: "Fetch and parse any public RSS/Atom feed URL (news sites, blogs, Sina/Weibo feeds, etc). Returns feed items with title, link, publish date and snippet.",
697
+ parameters: {
698
+ type: "object",
699
+ properties: {
700
+ url: { type: "string", description: "Full HTTP/HTTPS URL of the RSS/Atom feed" },
701
+ maxItems: { type: "number", description: "Max items to return (default 10, max 30)" }
702
+ },
703
+ required: ["url"]
704
+ }
705
+ }
706
+ },
707
+ {
708
+ type: "function",
709
+ function: {
710
+ name: "search_encyclopedia",
711
+ description: "Look up encyclopedia entries: Baidu Baike (百度百科) for Chinese terms, Wikipedia for Thai/English. Returns a structured summary with title, text, thumbnail and page URL. Use for background/biography/profile depth beyond the local catalog.",
712
+ parameters: {
713
+ type: "object",
714
+ properties: {
715
+ term: { type: "string", description: "Encyclopedia term to look up, e.g. '鞠婧祎', 'Ju Jingyi', 'SNH48'" },
716
+ language: { type: "string", enum: ["zh", "th", "en"], description: "Optional: force language. Defaults to auto-detect from the term." },
717
+ maxEntries: { type: "number", description: "Max encyclopedia entries to return (default 2, max 3)" }
718
+ },
719
+ required: ["term"]
720
+ }
721
+ }
722
+ },
723
+ {
724
+ type: "function",
725
+ function: {
726
+ name: "search_music_platforms",
727
+ description: "Search international music platforms (iTunes Search API + MusicBrainz, no API key needed) for songs/albums. Complements the local discography catalog with global platform data, previews and release metadata.",
728
+ parameters: {
729
+ type: "object",
730
+ properties: {
731
+ term: { type: "string", description: "Song/album/artist search term, e.g. 'Ju Jingyi' or '叹云兮'" },
732
+ maxResults: { type: "number", description: "Max results (default 8, max 20)" }
733
+ },
734
+ required: ["term"]
735
+ }
736
+ }
737
+ },
738
+ {
739
+ type: "function",
740
+ function: {
741
+ 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.",
743
+ parameters: {
744
+ type: "object",
745
+ properties: {
746
+ query: { type: "string", description: "Chinese search query, e.g. '鞠婧祎 微博'" },
747
+ maxResults: { type: "number", description: "Max results (default 8, max 15)" },
748
+ site: { type: "string", description: "Optional domain hint to restrict fallback search, e.g. 'sina.com.cn'" }
749
+ },
750
+ required: ["query"]
751
+ }
752
+ }
753
+ },
754
+ {
755
+ type: "function",
756
+ function: {
757
+ 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.",
759
+ parameters: {
760
+ type: "object",
761
+ properties: {
762
+ keyword: { type: "string", description: "Bilibili search keyword, e.g. '鞠婧祎 舞台'" },
763
+ maxResults: { type: "number", description: "Max results (default 8, max 20)" }
764
+ },
765
+ required: ["keyword"]
766
+ }
767
+ }
768
+ },
769
+ {
770
+ type: "function",
771
+ function: {
772
+ 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.",
774
+ parameters: {
775
+ type: "object",
776
+ properties: {
777
+ query: { type: "string", description: "Image search keyword or topic, e.g. '鞠婧祎 路透', '万花世界 剧照', 'Ju Jingyi red carpet'" },
778
+ maxResults: { type: "number", description: "Max images to return (default 8, max 20)" }
779
+ },
780
+ required: ["query"]
781
+ }
782
+ }
783
+ },
534
784
  {
535
785
  type: "function",
536
786
  function: {
@@ -687,7 +937,7 @@ export const toolsSchema = [
687
937
  type: "function",
688
938
  function: {
689
939
  name: "get_current_track",
690
- description: "Get information about the currently playing or paused track.",
940
+ description: "Get information about the currently playing or paused track only. For the full player state (queue, volume, mute) use get_music_status instead.",
691
941
  parameters: { type: "object", properties: {} }
692
942
  }
693
943
  },
@@ -695,7 +945,7 @@ export const toolsSchema = [
695
945
  type: "function",
696
946
  function: {
697
947
  name: "get_music_status",
698
- description: "Get full music player status including playing state, current track, and queue info.",
948
+ description: "Get FULL music player status in one call: playing/paused state, current track, queue contents, volume, and mute state. Prefer this over get_current_track.",
699
949
  parameters: { type: "object", properties: {} }
700
950
  }
701
951
  },
@@ -805,6 +1055,139 @@ export const toolsSchema = [
805
1055
  properties: {}
806
1056
  }
807
1057
  }
1058
+ },
1059
+ {
1060
+ type: "function",
1061
+ function: {
1062
+ name: "get_entertainment_metrics",
1063
+ description: "Query professional Chinese entertainment industry big data platforms (Vlinkage, DataWin, Guduo, Yunhe, Maoyan Pro, Dengta, Weibo, Douban, Xiaohongshu), popularity index, effective streaming views, and Ju Jingyi commercial benchmarks.",
1064
+ parameters: {
1065
+ type: "object",
1066
+ properties: {
1067
+ platform: {
1068
+ type: "string",
1069
+ enum: ["all", "vlinkage", "datawin", "guduo", "yunhe", "maoyan", "dengta", "weibo", "douban", "xiaohongshu"],
1070
+ description: "Specific platform ID or 'all'."
1071
+ },
1072
+ category: {
1073
+ type: "string",
1074
+ enum: ["all", "actor_drama_index", "streaming_boxoffice", "social_commercial"],
1075
+ description: "Category pillar filter."
1076
+ },
1077
+ query: {
1078
+ type: "string",
1079
+ description: "Optional search query to match platform name, metrics, or drama title (e.g. 'In Blossom', 'Yunhe', 'Vlinkage', 'Commercial')."
1080
+ }
1081
+ },
1082
+ required: []
1083
+ }
1084
+ }
1085
+ },
1086
+ {
1087
+ type: "function",
1088
+ function: {
1089
+ name: "get_industry_index",
1090
+ description: "DEPRECATED ALIAS — identical to get_entertainment_metrics. Call get_entertainment_metrics instead; kept only for backward compatibility.",
1091
+ parameters: {
1092
+ type: "object",
1093
+ properties: {
1094
+ platform: {
1095
+ type: "string",
1096
+ enum: ["all", "vlinkage", "datawin", "guduo", "yunhe", "maoyan", "dengta", "weibo", "douban", "xiaohongshu"],
1097
+ description: "Specific platform ID or 'all'."
1098
+ },
1099
+ category: {
1100
+ type: "string",
1101
+ enum: ["all", "actor_drama_index", "streaming_boxoffice", "social_commercial"],
1102
+ description: "Category pillar filter."
1103
+ },
1104
+ query: {
1105
+ type: "string",
1106
+ description: "Optional search query to match platform name, metrics, or drama title."
1107
+ }
1108
+ },
1109
+ required: []
1110
+ }
1111
+ }
1112
+ },
1113
+ {
1114
+ type: "function",
1115
+ function: {
1116
+ name: "checkpoint_step",
1117
+ description: "Record immediate progress and state checkpoint for an active run. Updates kiku/runs/{run_id}/state and plan checklist.",
1118
+ parameters: {
1119
+ type: "object",
1120
+ properties: {
1121
+ run_id: { type: "string", description: "The active run identifier (e.g. run_123)." },
1122
+ step_name: { type: "string", description: "Name of the subtask or step being executed." },
1123
+ status: { type: "string", enum: ["pending", "in_progress", "completed", "failed", "paused", "waiting_user"], description: "Current execution status of the step." },
1124
+ summary: { type: "string", description: "Concise summary of step execution or outcome." }
1125
+ },
1126
+ required: ["run_id", "step_name", "status", "summary"]
1127
+ }
1128
+ }
1129
+ },
1130
+ {
1131
+ type: "function",
1132
+ function: {
1133
+ name: "record_failure",
1134
+ description: "Record an error trace and tactical recovery action during task execution for episodic reflection.",
1135
+ parameters: {
1136
+ type: "object",
1137
+ properties: {
1138
+ run_id: { type: "string", description: "The active run identifier." },
1139
+ error_trace: { type: "string", description: "Error message or stack trace describing what failed." },
1140
+ recovery_action: { type: "string", description: "Tactical recovery action or workaround taken." },
1141
+ tool_name: { type: "string", description: "Optional name of the tool that encountered the failure." }
1142
+ },
1143
+ required: ["run_id", "error_trace", "recovery_action"]
1144
+ }
1145
+ }
1146
+ },
1147
+ {
1148
+ type: "function",
1149
+ function: {
1150
+ name: "load_active_run",
1151
+ description: "Load active run state, execution plan, and checkpoint history for task hydration and resuming.",
1152
+ parameters: {
1153
+ type: "object",
1154
+ properties: {
1155
+ run_id: { type: "string", description: "The run identifier to load." }
1156
+ },
1157
+ required: ["run_id"]
1158
+ }
1159
+ }
1160
+ },
1161
+ {
1162
+ type: "function",
1163
+ function: {
1164
+ name: "record_dynamic_event",
1165
+ description: "Record a newly verified live event, brand activity, appearance, or work into the local dynamic catalog overlay. Persists immediately across restarts without modifying static source files.",
1166
+ parameters: {
1167
+ type: "object",
1168
+ properties: {
1169
+ category: {
1170
+ type: "string",
1171
+ enum: ["brands", "timeline", "gallery", "works", "events"],
1172
+ description: "Catalog category to record this event into."
1173
+ },
1174
+ entry: {
1175
+ type: "object",
1176
+ description: "The structured catalog entry to record (must match category schema, e.g. year, title/brand, etc.)."
1177
+ },
1178
+ source: {
1179
+ type: "string",
1180
+ description: "Origin source URL or platform (e.g. Douyin, Weibo, Official Press)."
1181
+ },
1182
+ trustTier: {
1183
+ type: "number",
1184
+ enum: [1, 2, 3],
1185
+ description: "Trust tier of the information source (1=Official, 2=Press, 3=Community/Other)."
1186
+ }
1187
+ },
1188
+ required: ["category", "entry"]
1189
+ }
1190
+ }
808
1191
  }
809
1192
  ];
810
1193
 
@@ -816,10 +1199,22 @@ const TOOL_DEFAULT_TIMEOUTS = {
816
1199
  suggest_topics: 120_000,
817
1200
  web_extract: 90_000,
818
1201
  screenshot_webpage: 90_000,
1202
+ get_youtube_transcript: 90_000,
819
1203
  search_youtube: 60_000,
820
1204
  play_music: 60_000,
1205
+ search_news: 45_000,
1206
+ fetch_rss: 30_000,
1207
+ search_encyclopedia: 45_000,
1208
+ search_music_platforms: 45_000,
1209
+ search_chinese_web: 60_000,
1210
+ search_bilibili: 45_000,
1211
+ search_web_images: 60_000,
821
1212
  };
822
1213
 
1214
+ const isCnWebEnabled = () => process.env.KIKU_ENABLE_CN_WEB_TOOLS === 'true';
1215
+ 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.`;
1217
+
823
1218
  async function ensureLlmAvailable(client) {
824
1219
  if (!client || typeof client.checkConnection !== 'function') return null;
825
1220
  try {
@@ -837,26 +1232,56 @@ async function ensureLlmAvailable(client) {
837
1232
  return null;
838
1233
  }
839
1234
 
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
- ];
1235
+ import { RUMORED_OR_EXTERNAL_PROJECTS } from './rumoredProjects.js';
1236
+
1237
+ export { RUMORED_OR_EXTERNAL_PROJECTS };
854
1238
 
855
1239
  export const toolHandlers = {
856
- get_profile_and_fandom: async () => {
857
- const d = profileData.details || {};
1240
+ get_current_datetime: async () => {
1241
+ const now = new Date();
1242
+ const offsetMinutes = -now.getTimezoneOffset();
1243
+ const sign = offsetMinutes >= 0 ? '+' : '-';
1244
+ const absOffset = Math.abs(offsetMinutes);
1245
+ const timezoneOffset = `${sign}${String(Math.floor(absOffset / 60)).padStart(2, '0')}:${String(absOffset % 60).padStart(2, '0')}`;
1246
+ let timezoneName = null;
1247
+ try {
1248
+ timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
1249
+ } catch { }
858
1250
  return {
859
1251
  status: "success",
1252
+ iso: now.toISOString(),
1253
+ local: now.toLocaleString('en-GB', { hour12: false }),
1254
+ timezoneOffset,
1255
+ timezoneName,
1256
+ weekday: now.toLocaleDateString('en-US', { weekday: 'long' }),
1257
+ year: now.getFullYear(),
1258
+ month: now.getMonth() + 1,
1259
+ day: now.getDate(),
1260
+ timestampMs: now.getTime()
1261
+ };
1262
+ },
1263
+
1264
+ get_youtube_transcript: async (args = {}) => fetchYoutubeTranscript(args),
1265
+
1266
+ get_profile_and_fandom: async ({ section, query } = {}) => {
1267
+ const d = profileData.details || {};
1268
+ const qStr = String(query || '').toLowerCase().trim();
1269
+ const isCatQuery = /(cat|cats|แมว|สัตว์เลี้ยง|pet|pets|miaomiao)/i.test(qStr);
1270
+
1271
+ if (isCatQuery) {
1272
+ return {
1273
+ status: "success",
1274
+ query: query || "cats",
1275
+ nameTh: profileData.nameTh,
1276
+ nameCn: profileData.nameCn,
1277
+ petsSummary: d.petsSummary || "แมว 3 ตัว: ฮวาฮวา (Huahua), เมี่ยวเมี่ยว (Miaomiao), เลิฟเวอร์ (Lover)",
1278
+ cats: profileData.cats || []
1279
+ };
1280
+ }
1281
+
1282
+ const result = {
1283
+ status: "success",
1284
+ query: query || undefined,
860
1285
  profile: {
861
1286
  nameTh: profileData.nameTh,
862
1287
  nameCn: profileData.nameCn,
@@ -878,10 +1303,28 @@ export const toolHandlers = {
878
1303
  cats: profileData.cats || [],
879
1304
  fandomName: d.fandomName || "มี่จวี (Miju / 蜜橘)",
880
1305
  fandomColor: d.fandomColorName || "ฟ้าคริสตัล & ชมพูซากุระ",
881
- slogan: profileData.motto || "保持热爱,奔赴山海",
882
- youtubeSearchUrl: buildYouTubeSearchUrl("Ju Jingyi 鞠婧祎")
1306
+ slogan: profileData.motto || "保持热爱,奔赴山海"
883
1307
  }
884
1308
  };
1309
+
1310
+ const requested = String(section || 'all').toLowerCase();
1311
+ if (requested === 'talents' || requested === 'all') {
1312
+ result.talents = (profileData.talents || []).map(t => ({
1313
+ id: t.id, title: t.title, titleCn: t.titleCn, level: t.level, description: t.description
1314
+ }));
1315
+ }
1316
+ if (requested === 'stats' || requested === 'all') {
1317
+ result.stats = profileData.stats || [];
1318
+ }
1319
+ if (requested === 'honors' || requested === 'all') {
1320
+ result.honors = profileData.forbesHonors || [];
1321
+ }
1322
+ if (requested === 'social' || requested === 'all') {
1323
+ result.socialLinks = (profileData.socialLinks || []).map(s => ({
1324
+ name: s.name, url: s.url, username: s.username, type: s.type, badge: s.badge
1325
+ }));
1326
+ }
1327
+ return result;
885
1328
  },
886
1329
 
887
1330
  get_quotes: async ({ tag } = {}) => {
@@ -907,20 +1350,44 @@ export const toolHandlers = {
907
1350
  },
908
1351
 
909
1352
  search_works: async ({ query, year, category, featuredOnly } = {}) => {
910
- let results = [...worksData];
1353
+ const dynamicWorks = getDynamicEntries('works');
1354
+ const baseWorks = dynamicWorks && dynamicWorks.length > 0 ? [...dynamicWorks, ...worksData] : [...worksData];
1355
+ let results = [...baseWorks];
911
1356
 
912
1357
  if (year) results = results.filter(w => w.year === Number(year));
913
- if (category && category !== 'all') results = results.filter(w => w.category === category);
1358
+ const categoryFilter = normalizeWorkCategoryFilter(category);
1359
+ if (categoryFilter) results = results.filter(w => categoryFilter.includes(w.category));
914
1360
 
1361
+ let broadMatch = false;
1362
+ let matcher = null;
915
1363
  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));
1364
+ matcher = createCatalogMatcher(query);
1365
+ if (matcher.broad) {
1366
+ broadMatch = true;
1367
+ } else {
1368
+ results = results.filter(w => matcher(
1369
+ w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis,
1370
+ ...(w.aliases || []), ...(w.keywords || [])
1371
+ ));
1372
+ }
918
1373
  }
919
1374
 
920
1375
  if (featuredOnly && results.some(w => !!w.featured)) {
921
1376
  results = results.filter(w => !!w.featured);
922
1377
  }
923
1378
 
1379
+ if (results.length === 0 && query && (categoryFilter || year)) {
1380
+ let relaxed = [...baseWorks];
1381
+ const fallbackMatcher = matcher || createCatalogMatcher(query);
1382
+ relaxed = relaxed.filter(w => fallbackMatcher(
1383
+ w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn, w.synopsis,
1384
+ ...(w.aliases || []), ...(w.keywords || [])
1385
+ ));
1386
+ if (relaxed.length > 0) {
1387
+ results = relaxed;
1388
+ }
1389
+ }
1390
+
924
1391
  if (results.length === 0 && query) {
925
1392
  const qLower = String(query).toLowerCase().trim();
926
1393
  const rumored = RUMORED_OR_EXTERNAL_PROJECTS.find(p => p.keywords.some(k => qLower.includes(k)));
@@ -937,7 +1404,7 @@ export const toolHandlers = {
937
1404
 
938
1405
  results.sort((a, b) => (b.year || 0) - (a.year || 0));
939
1406
 
940
- return {
1407
+ const result = {
941
1408
  status: "success",
942
1409
  count: results.length,
943
1410
  works: results.slice(0, 6).map(w => {
@@ -950,14 +1417,15 @@ export const toolHandlers = {
950
1417
  titleEn: w.titleEn,
951
1418
  year: w.year,
952
1419
  category: w.category,
953
- role: w.role,
954
- roleCn: w.roleCn,
955
- synopsis: w.synopsis || null,
956
- highlights: (w.highlights || []).slice(0, 3),
1420
+ role: w.role,
1421
+ roleCn: w.roleCn,
1422
+ synopsis: w.synopsis || null,
1423
+ highlights: (w.highlights || []).slice(0, 3),
957
1424
  episodes: w.episodes,
958
1425
  rating: w.rating,
959
1426
  platform: w.streamPlatform,
960
1427
  streamUrl: w.streamUrl || null,
1428
+ poster: w.poster || null,
961
1429
  youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
962
1430
  youtubeTrailerIsDirectLink: false,
963
1431
  youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
@@ -968,22 +1436,33 @@ export const toolHandlers = {
968
1436
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
969
1437
  } : null
970
1438
  };
971
- })
1439
+ }),
1440
+ images: results.slice(0, 6).map(w => ({
1441
+ title: `${w.titleTh || w.titleCn} (${w.year})`,
1442
+ imageUrl: w.poster || null,
1443
+ thumbUrl: w.poster || null,
1444
+ source: 'Series Poster'
1445
+ })).filter(img => img.imageUrl)
972
1446
  };
1447
+ if (broadMatch) {
1448
+ result.message = `Broad query "${query}": every search term was generic, so no title/character filter was applied. The works below are the newest catalog entries, not an exact match for "${query}" — say so explicitly if you use them.`;
1449
+ }
1450
+ return result;
973
1451
  },
974
1452
 
975
1453
  get_work_detail: async ({ workIdOrTitle }) => {
976
1454
  if (!workIdOrTitle) return { status: "error", message: "workIdOrTitle parameter is required." };
977
1455
  const q = workIdOrTitle.toLowerCase().trim();
978
1456
 
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 ||
983
- w.titleTh.toLowerCase().includes(q) ||
984
- (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
985
- (w.titleCn && w.titleCn.includes(workIdOrTitle))
986
- );
1457
+ const qId = normalizeWorkId(workIdOrTitle);
1458
+ let work = WORKS_BY_NORM_ID.get(qId) || null;
1459
+ if (!work) {
1460
+ work = worksData.find(w =>
1461
+ w.titleTh.toLowerCase().includes(q) ||
1462
+ (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
1463
+ (w.titleCn && w.titleCn.includes(workIdOrTitle))
1464
+ );
1465
+ }
987
1466
  if (!work) {
988
1467
 
989
1468
  const matcher = createCatalogMatcher(workIdOrTitle);
@@ -1006,8 +1485,8 @@ export const toolHandlers = {
1006
1485
  titleEn: work.titleEn,
1007
1486
  year: work.year,
1008
1487
  category: work.category,
1009
- role: work.role,
1010
- roleCn: work.roleCn,
1488
+ role: work.role,
1489
+ roleCn: work.roleCn,
1011
1490
  episodes: work.episodes,
1012
1491
  rating: work.rating,
1013
1492
  platform: work.streamPlatform,
@@ -1016,7 +1495,7 @@ export const toolHandlers = {
1016
1495
  youtubeTrailerIsDirectLink: false,
1017
1496
  youkuSearchUrl: buildYoukuSearchUrl(work.titleCn || work.titleEn),
1018
1497
  bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${work.titleCn}`),
1019
- synopsis: work.synopsis || null,
1498
+ synopsis: work.synopsis || null,
1020
1499
  highlights: (work.highlights || []).slice(0, 3),
1021
1500
  themeSong: work.themeSong ? {
1022
1501
  title: formatCatalogTitle(work.themeSong.titleTh, work.themeSong.titleCn),
@@ -1029,7 +1508,26 @@ export const toolHandlers = {
1029
1508
  },
1030
1509
 
1031
1510
  get_character_roles: async ({ query } = {}) => {
1032
- const roles = worksData.map(w => ({
1511
+ if (query) {
1512
+ const qLower = String(query).toLowerCase();
1513
+ const isPersonal = PERSONAL_FACT_KEYWORDS.some(k => qLower.includes(k));
1514
+ if (isPersonal) {
1515
+ return {
1516
+ status: "misdirected",
1517
+ count: 0,
1518
+ roles: [],
1519
+ message: "get_character_roles contains fictional drama and movie characters only. For Ju Jingyi's personal facts, birthplace, education, musical instruments, blood type, or biography, call get_profile_and_fandom."
1520
+ };
1521
+ }
1522
+ }
1523
+
1524
+ let matchedWorks = worksData;
1525
+ if (query) {
1526
+ const matcher = createCatalogMatcher(query);
1527
+ matchedWorks = worksData.filter(w => matcher(w.role, w.roleCn, formatCatalogTitle(w.titleTh, w.titleCn), w.titleTh, w.titleCn));
1528
+ }
1529
+
1530
+ const filtered = matchedWorks.map(w => ({
1033
1531
  workTitle: formatCatalogTitle(w.titleTh, w.titleCn),
1034
1532
  year: w.year,
1035
1533
  role: w.role,
@@ -1037,12 +1535,6 @@ export const toolHandlers = {
1037
1535
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.roleCn || w.role} ${w.titleCn}`)
1038
1536
  }));
1039
1537
 
1040
- let filtered = roles;
1041
- if (query) {
1042
- const matcher = createCatalogMatcher(query);
1043
- filtered = roles.filter(r => matcher(r.role, r.roleCn, r.workTitle));
1044
- }
1045
-
1046
1538
  filtered.sort((a, b) => (b.year || 0) - (a.year || 0));
1047
1539
 
1048
1540
  return {
@@ -1053,7 +1545,9 @@ export const toolHandlers = {
1053
1545
  },
1054
1546
 
1055
1547
  search_discography: async ({ query, year, type } = {}) => {
1056
- let list = getCleanDiscography();
1548
+ let rawList = getCleanDiscography();
1549
+ let list = [...rawList];
1550
+ let broadMatch = false;
1057
1551
 
1058
1552
  if (year) list = list.filter(s => s.year === Number(year));
1059
1553
  if (type === 'ost') list = list.filter(s => s.work && s.work !== "Solo Single");
@@ -1071,20 +1565,99 @@ export const toolHandlers = {
1071
1565
  };
1072
1566
  }
1073
1567
  const matcher = createCatalogMatcher(query);
1074
- list = list.filter(s => matcher(s.titleTh, s.titleCn, s.titleEn, s.work, s.role, s.description));
1568
+ if (matcher.broad) {
1569
+ broadMatch = true;
1570
+ } else {
1571
+ list = list.filter(s => matcher(
1572
+ s.titleTh, s.titleCn, s.titleEn, s.work, s.role, s.description,
1573
+ ...(s.aliases || []), ...(s.keywords || [])
1574
+ ));
1575
+ }
1576
+
1577
+ if (list.length === 0 && (type || year)) {
1578
+ let relaxed = [...rawList];
1579
+ relaxed = relaxed.filter(s => matcher(
1580
+ s.titleTh, s.titleCn, s.titleEn, s.work, s.role, s.description,
1581
+ ...(s.aliases || []), ...(s.keywords || [])
1582
+ ));
1583
+ if (relaxed.length > 0) {
1584
+ list = relaxed;
1585
+ }
1586
+ }
1587
+
1588
+ const qClean = qLower;
1589
+ const scoredList = list.map(item => {
1590
+ const titles = [item.titleEn, item.titleCn, item.titleTh, ...(item.aliases || [])];
1591
+ let score = 0;
1592
+ for (const t of titles) {
1593
+ if (!t) continue;
1594
+ const lower = String(t).toLowerCase().trim();
1595
+ if (lower === qClean) {
1596
+ score = 2;
1597
+ break;
1598
+ }
1599
+ if (score < 1 && lower.includes(qClean)) {
1600
+ score = 1;
1601
+ }
1602
+ }
1603
+ return { item, score };
1604
+ });
1605
+ scoredList.sort((a, b) => b.score - a.score);
1606
+ list = scoredList.map(s => s.item);
1607
+ }
1608
+
1609
+ if (list.length === 0 && query) {
1610
+ const allMVs = getAllMusicVideos();
1611
+ const mvMatcher = createCatalogMatcher(query);
1612
+ const mvMatches = allMVs.filter(m => mvMatcher(
1613
+ m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description,
1614
+ ...(m.aliases || []), ...(m.keywords || [])
1615
+ ));
1616
+ if (mvMatches.length > 0) {
1617
+ return {
1618
+ status: "success",
1619
+ source: "musicVideoData",
1620
+ cascadeMatched: true,
1621
+ count: mvMatches.length,
1622
+ message: `Cascade retrieval: Matched in music videos catalog (musicVideoData) for "${query}".`,
1623
+ songs: mvMatches.slice(0, 5).map(m => ({
1624
+ id: m.id,
1625
+ title: formatCatalogTitle(m.titleTh, m.titleCn),
1626
+ titleEn: m.titleCn,
1627
+ titleCn: m.titleCn,
1628
+ year: m.releaseYear || m.year,
1629
+ type: m.category || "Single",
1630
+ work: m.dramaName || "Music Video",
1631
+ category: m.category,
1632
+ duration: m.duration,
1633
+ youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1634
+ isDirectLink: Boolean(m.youtubeId),
1635
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh}`),
1636
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
1637
+ }))
1638
+ };
1639
+ }
1075
1640
  }
1076
1641
 
1077
1642
  return {
1078
1643
  status: list.length > 0 ? "success" : "not_found",
1079
- message: list.length > 0 ? undefined : `No matching discography entries for "${query || 'this filter'}".`,
1644
+ message: list.length > 0
1645
+ ? (broadMatch ? `Broad query "${query}": every search term was generic, so no title filter was applied. The songs below are the newest catalog entries, not an exact match for "${query}" — say so explicitly if you use them.` : undefined)
1646
+ : `No matching discography entries for "${query || 'this filter'}".`,
1080
1647
  count: list.length,
1081
1648
  songs: list.slice(0, 8).map(s => ({
1082
1649
  id: s.id,
1083
1650
  title: formatCatalogTitle(s.titleTh, s.titleCn),
1084
1651
  titleEn: s.titleEn,
1652
+ titleCn: s.titleCn,
1085
1653
  year: s.year,
1086
1654
  type: s.work,
1087
- duration: s.duration,
1655
+ work: s.work,
1656
+ category: s.category,
1657
+ categoryName: s.categoryName,
1658
+ role: s.role,
1659
+ description: s.description,
1660
+ duration: s.duration || s.trackDuration,
1088
1661
  youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
1089
1662
  isDirectLink: Boolean(s.youtubeUrl || s.youtubeId),
1090
1663
  youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
@@ -1098,7 +1671,7 @@ export const toolHandlers = {
1098
1671
  const allMVs = getAllMusicVideos();
1099
1672
 
1100
1673
  let results = allMVs;
1101
- if (category) {
1674
+ if (category && category.toLowerCase() !== 'all') {
1102
1675
  const catLower = category.toLowerCase();
1103
1676
  results = results.filter(m => {
1104
1677
  const mCat = (m.category || '').toLowerCase();
@@ -1112,7 +1685,10 @@ export const toolHandlers = {
1112
1685
 
1113
1686
  if (query) {
1114
1687
  const matcher = createCatalogMatcher(query);
1115
- results = results.filter(m => matcher(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description));
1688
+ results = results.filter(m => matcher(
1689
+ m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description,
1690
+ ...(m.aliases || []), ...(m.keywords || [])
1691
+ ));
1116
1692
  }
1117
1693
 
1118
1694
  return {
@@ -1124,12 +1700,14 @@ export const toolHandlers = {
1124
1700
  return {
1125
1701
  id: m.id,
1126
1702
  title: formatCatalogTitle(m.titleTh, m.titleCn),
1703
+ titleCn: m.titleCn || null,
1127
1704
  category: m.category,
1128
1705
  year: m.releaseYear || m.year,
1129
1706
  drama: m.dramaName || null,
1130
1707
  duration: m.duration || null,
1131
1708
  description: m.description || null,
1132
1709
  isFeatured: !!m.isFeatured,
1710
+ youtubeId: ytId,
1133
1711
  youtubeUrl: ytId ? buildYouTubeWatchUrl(ytId) : null,
1134
1712
  isDirectLink: Boolean(ytId),
1135
1713
  youtubeSearchUrl: buildYouTubeSearchUrl(searchTitle),
@@ -1157,6 +1735,8 @@ export const toolHandlers = {
1157
1735
  title: formatCatalogTitle(m.titleTh, m.titleCn),
1158
1736
  year: m.releaseYear || m.year,
1159
1737
  duration: m.duration,
1738
+ drama: m.dramaName || null,
1739
+ description: m.description || null,
1160
1740
  youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1161
1741
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage' : 'Official MV'}`),
1162
1742
  bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
@@ -1170,8 +1750,12 @@ export const toolHandlers = {
1170
1750
  ).map(s => ({
1171
1751
  type: s.work || 'Song',
1172
1752
  title: formatCatalogTitle(s.titleTh, s.titleCn),
1753
+ titleEn: s.titleEn || null,
1173
1754
  year: s.year,
1174
1755
  duration: s.duration,
1756
+ work: s.work || null,
1757
+ role: s.role || null,
1758
+ description: s.description || null,
1175
1759
  youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
1176
1760
  youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
1177
1761
  qqMusicSearchUrl: s.qqMusicSearchUrl || buildQqMusicSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`),
@@ -1186,6 +1770,9 @@ export const toolHandlers = {
1186
1770
  type: 'Drama / Series',
1187
1771
  title: formatCatalogTitle(w.titleTh, w.titleCn),
1188
1772
  year: w.year,
1773
+ role: w.role,
1774
+ roleCn: w.roleCn,
1775
+ synopsis: w.synopsis || null,
1189
1776
  platform: w.streamPlatform,
1190
1777
  streamUrl: w.streamUrl || null,
1191
1778
  youtubeTrailerSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer Official`),
@@ -1200,6 +1787,21 @@ export const toolHandlers = {
1200
1787
  const generalYouTubeSearch = buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`);
1201
1788
  const generalBilibiliSearch = buildBilibiliSearchUrl(`鞠婧祎 ${query}`);
1202
1789
 
1790
+ const brandItems = Array.isArray(brandsData) ? brandsData : (brandsData.brandList || brandsData.brands || []);
1791
+ const matchedBrands = brandItems.filter(b => {
1792
+ const bEn = String(b.brandNameEn || b.brand || b.name || '').toLowerCase();
1793
+ const bCn = String(b.brandNameCn || '');
1794
+ return bEn.includes(q) || (bCn && query.includes(bCn)) || (bCn && bCn.includes(query)) || (b.id && b.id.toLowerCase().includes(q));
1795
+ }).map(b => ({
1796
+ type: 'Brand / Commercial',
1797
+ brand: `${b.brandNameEn || b.brand || b.name} (${b.brandNameCn || ''})`,
1798
+ year: b.year,
1799
+ title: b.title,
1800
+ category: b.category,
1801
+ youtubeCommercialSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${b.brandNameEn || b.brandNameCn} Commercial`),
1802
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${b.brandNameCn || b.brandNameEn}`)
1803
+ }));
1804
+
1203
1805
  return {
1204
1806
  status: "success",
1205
1807
  query,
@@ -1208,57 +1810,164 @@ export const toolHandlers = {
1208
1810
  results: {
1209
1811
  musicVideos: matchedVideos.slice(0, 5),
1210
1812
  songs: matchedSongs.slice(0, 5),
1211
- works: matchedWorks.slice(0, 3)
1813
+ works: matchedWorks.slice(0, 3),
1814
+ brands: matchedBrands.slice(0, 3)
1212
1815
  }
1213
1816
  };
1214
1817
  },
1215
1818
 
1216
1819
  get_brand_endorsements: async ({ isGlobal, category, year, query } = {}) => {
1217
- let list = Array.isArray(brandsData) ? [...brandsData] : [...(brandsData.brandList || brandsData.brands || [])];
1820
+ const dynamicBrands = getDynamicEntries('brands');
1821
+ let rawList = Array.isArray(brandsData) ? [...brandsData] : [...(brandsData.brandList || brandsData.brands || [])];
1822
+ if (dynamicBrands && dynamicBrands.length > 0) {
1823
+ rawList = [...dynamicBrands, ...rawList];
1824
+ }
1825
+ let list = [...rawList];
1218
1826
 
1219
1827
  if (typeof isGlobal === 'boolean') list = list.filter(b => !!b.isGlobal === isGlobal);
1220
1828
  if (year) list = list.filter(b => b.year === Number(year));
1221
- if (category) list = list.filter(b => (b.category && b.category.toLowerCase().includes(category.toLowerCase())) || (b.tier && b.tier.toLowerCase().includes(category.toLowerCase())));
1222
1829
 
1830
+ const matchesCategory = (b, cat) => {
1831
+ const cLower = String(cat).toLowerCase().trim();
1832
+ if ((b.category && b.category.toLowerCase().includes(cLower)) || (b.tier && b.tier.toLowerCase().includes(cLower))) return true;
1833
+ if (CATEGORY_ALIASES) {
1834
+ for (const [canonical, aliases] of Object.entries(CATEGORY_ALIASES)) {
1835
+ if (cLower.includes(canonical) || aliases.some(a => cLower.includes(a.toLowerCase()))) {
1836
+ const bText = `${b.category || ''} ${b.tier || ''} ${(b.keywords || []).join(' ')} ${(b.aliases || []).join(' ')}`.toLowerCase();
1837
+ if (aliases.some(a => bText.includes(a.toLowerCase()))) return true;
1838
+ }
1839
+ }
1840
+ }
1841
+ return false;
1842
+ };
1843
+
1844
+ if (category) list = list.filter(b => matchesCategory(b, category));
1845
+
1846
+ let activeMatcher = null;
1223
1847
  if (query) {
1224
1848
  const numYear = Number(String(query).trim());
1225
1849
  if (!isNaN(numYear) && numYear >= 2013 && numYear <= 2030) {
1226
1850
  list = list.filter(b => b.year === numYear);
1227
1851
  } else {
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));
1852
+ activeMatcher = createCatalogMatcher(query);
1853
+ list = list.filter(b => activeMatcher(
1854
+ b.brandNameEn, b.brandNameCn, b.brand, b.name, b.title, b.category, b.highlight, b.tier,
1855
+ ...(b.aliases || []), ...(b.keywords || [])
1856
+ ));
1857
+ }
1858
+ }
1859
+
1860
+ if (list.length === 0 && (category || query)) {
1861
+ let relaxed = [...rawList];
1862
+ if (typeof isGlobal === 'boolean') relaxed = relaxed.filter(b => !!b.isGlobal === isGlobal);
1863
+ if (year) relaxed = relaxed.filter(b => b.year === Number(year));
1864
+ const targetQuery = query || category;
1865
+ if (targetQuery) {
1866
+ const matcher = (targetQuery === query && activeMatcher) ? activeMatcher : createCatalogMatcher(targetQuery);
1867
+ relaxed = relaxed.filter(b => matcher(
1868
+ b.brandNameEn, b.brandNameCn, b.brand, b.name, b.title, b.category, b.highlight, b.tier,
1869
+ ...(b.aliases || []), ...(b.keywords || [])
1870
+ ));
1871
+ if (relaxed.length > 0) {
1872
+ list = relaxed;
1873
+ }
1230
1874
  }
1231
1875
  }
1232
1876
 
1233
1877
  list.sort((a, b) => (b.year || 0) - (a.year || 0));
1234
1878
 
1879
+ if (list.length === 0 && query) {
1880
+ const qLower = String(query).toLowerCase().trim();
1881
+ const isMedia = /poi|song|single|video|mv|ost|เพลง|ละคร|ซีรีส์|บทบาท/i.test(qLower);
1882
+ return {
1883
+ status: "not_found",
1884
+ count: 0,
1885
+ brands: [],
1886
+ message: isMedia
1887
+ ? `No brand endorsement matching "${query}". "${query}" appears to be a song, music video, or drama work. Please call search_discography, get_music_videos, or search_works instead.`
1888
+ : `No brand endorsements found for query "${query}".`
1889
+ };
1890
+ }
1891
+
1892
+ const dynamicSales = dynamicBrands ? dynamicBrands.filter(b => b.salesHighlights || b.stat).flatMap(b => b.salesHighlights || [{ brand: b.brandNameEn || b.brand, detail: b.highlight || '', stat: b.stat || '' }]) : [];
1893
+ const allSales = [...dynamicSales, ...(Array.isArray(brandsData?.salesHighlights) ? brandsData.salesHighlights : [])];
1894
+ let relevantSales = allSales;
1895
+ if (query) {
1896
+ const qLower = String(query).toLowerCase().trim();
1897
+ const filtered = allSales.filter(s =>
1898
+ (s.brand && s.brand.toLowerCase().includes(qLower)) ||
1899
+ (s.brandId && s.brandId.toLowerCase().includes(qLower)) ||
1900
+ (s.detail && s.detail.toLowerCase().includes(qLower)) ||
1901
+ (s.stat && s.stat.toLowerCase().includes(qLower))
1902
+ );
1903
+ if (filtered.length > 0) relevantSales = filtered;
1904
+ }
1905
+
1235
1906
  return {
1236
1907
  status: "success",
1237
1908
  count: list.length,
1238
- brands: list.slice(0, 8).map(b => ({
1239
- brand: `${b.brandNameEn || b.brand || b.name} (${b.brandNameCn || ''})`,
1240
- year: b.year,
1241
- title: b.title || (b.isGlobal ? 'Global Brand Ambassador' : 'Brand Ambassador'),
1242
- category: b.category,
1243
- isGlobal: !!b.isGlobal,
1244
- highlight: b.highlight || '',
1245
- youtubeCommercialSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${b.brandNameEn || b.brandNameCn} CF Commercial`)
1246
- }))
1909
+ brands: list.slice(0, 8).map(b => {
1910
+ const brandName = b.brandNameEn || b.brand || b.name || '';
1911
+ const brandCn = b.brandNameCn || '';
1912
+ return {
1913
+ brand: `${brandName} (${brandCn})`.trim(),
1914
+ brandNameEn: brandName,
1915
+ brandNameCn: brandCn,
1916
+ year: b.year,
1917
+ title: b.title || (b.isGlobal ? 'Global Brand Ambassador' : 'Brand Ambassador'),
1918
+ category: b.category,
1919
+ isGlobal: !!b.isGlobal,
1920
+ highlight: b.highlight || '',
1921
+ youtubeCommercialSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${brandName || brandCn} CF Commercial`),
1922
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${brandCn || brandName}`)
1923
+ };
1924
+ }),
1925
+ salesHighlights: relevantSales
1247
1926
  };
1248
1927
  },
1249
1928
 
1250
- get_career_timeline: async ({ year, era } = {}) => {
1251
- let eras = Array.isArray(timelineData) ? timelineData : (timelineData.eras || []);
1252
- if (year) eras = eras.filter(e => e.year == year || (e.period && e.period.includes(String(year))));
1253
- if (era) eras = eras.filter(e => e.title?.toLowerCase().includes(era.toLowerCase()) || e.era?.includes(era));
1929
+ get_career_timeline: async ({ year, era, limit = 10 } = {}) => {
1930
+ const dynamicTimeline = [...getDynamicEntries('timeline'), ...getDynamicEntries('events')];
1931
+ let eras = Array.isArray(timelineData) ? [...timelineData] : [...(timelineData.eras || [])];
1932
+ if (dynamicTimeline && dynamicTimeline.length > 0) {
1933
+ eras = [...dynamicTimeline, ...eras];
1934
+ }
1935
+ if (year) eras = eras.filter(e => e.year == year || (e.period && String(e.period).includes(String(year))) || (e.date && String(e.date).includes(String(year))));
1936
+ if (era) {
1937
+ const q = String(era).toLowerCase().trim();
1938
+ const qNorm = q.replace(/\s*era\s*/gi, '').trim();
1939
+ eras = eras.filter(e => {
1940
+ const eEra = String(e.era || '').toLowerCase().trim();
1941
+ const eEraNorm = eEra.replace(/\s*era\s*/gi, '').trim();
1942
+ const eTitle = String(e.title || '').toLowerCase();
1943
+ const eTitleCn = String(e.titleCn || '');
1944
+ const eCategory = String(e.category || '').toLowerCase();
1945
+ const eSummary = String(e.summary || '').toLowerCase();
1946
+ return (
1947
+ (eEra && (eEra.includes(q) || (qNorm && eEraNorm.includes(qNorm)) || (qNorm && qNorm.includes(eEraNorm)))) ||
1948
+ eTitle.includes(q) || (qNorm && eTitle.includes(qNorm)) ||
1949
+ eTitleCn.includes(era) ||
1950
+ eCategory.includes(q) || (qNorm && eCategory.includes(qNorm)) ||
1951
+ eSummary.includes(q) || (qNorm && eSummary.includes(qNorm))
1952
+ );
1953
+ });
1954
+ }
1254
1955
 
1956
+ const safeLimit = Math.min(Math.max(Number(limit) || 10, 1), 30);
1255
1957
  return {
1256
- status: "success",
1958
+ status: eras.length > 0 ? "success" : "not_found",
1257
1959
  count: eras.length,
1258
- milestones: eras.slice(0, 6).map(e => ({
1960
+ milestones: eras.slice(0, safeLimit).map(e => ({
1961
+ id: e.id,
1962
+ year: e.year,
1963
+ date: e.date || e.period || String(e.year || ''),
1259
1964
  period: e.period || e.year,
1260
1965
  title: formatCatalogTitle(e.title, e.titleCn),
1261
- summary: e.summary?.slice(0, 150),
1966
+ titleCn: e.titleCn,
1967
+ era: e.era,
1968
+ category: e.category,
1969
+ summary: e.summary,
1970
+ highlights: e.highlights || [],
1262
1971
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${e.titleCn || e.title}`)
1263
1972
  }))
1264
1973
  };
@@ -1272,15 +1981,27 @@ export const toolHandlers = {
1272
1981
  let usedQueryFilter = false;
1273
1982
  if (query) {
1274
1983
  const q = String(query).toLowerCase().trim();
1275
-
1984
+
1276
1985
  const generic = /(ju jingyi|jujingyi|จวีจิ้งอี|จิ้งอี|จวี|鞠婧祎|小鞠|kiku|kikuuu|ju jing yi|蜜橘)/i.test(q);
1277
1986
  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;
1987
+ const matcher = createCatalogMatcher(q);
1988
+ if (matcher.broad) {
1989
+
1990
+ } else {
1991
+ const filtered = list.filter(a => matcher(a.titleTh, a.titleCn, a.titleEn, a.ceremony, a.category, a.work));
1992
+ if (filtered.length > 0) {
1993
+ list = filtered;
1994
+ usedQueryFilter = true;
1995
+ } else {
1996
+ return {
1997
+ status: "not_found",
1998
+ count: 0,
1999
+ awards: [],
2000
+ query,
2001
+ message: `No awards match the query "${query}" in the local catalog. Do NOT present other awards as an answer to this query. Use web_search or web_extract for external information, or call get_awards without a query to list all awards.`
2002
+ };
2003
+ }
1282
2004
  }
1283
-
1284
2005
  }
1285
2006
  }
1286
2007
 
@@ -1327,20 +2048,36 @@ export const toolHandlers = {
1327
2048
  },
1328
2049
 
1329
2050
  get_gallery_photos: async ({ category, query, year, limit = 4 } = {}) => {
1330
- let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
2051
+ const dynamicPhotos = getDynamicEntries('gallery');
2052
+ let photos = Array.isArray(galleryData) ? [...galleryData] : [...(galleryData.photos || [])];
2053
+ if (dynamicPhotos && dynamicPhotos.length > 0) {
2054
+ photos = [...dynamicPhotos, ...photos];
2055
+ }
1331
2056
  if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1332
2057
  let usedQueryFilter = false;
1333
2058
  if (query) {
1334
2059
  const q = String(query).toLowerCase().trim();
1335
-
2060
+
1336
2061
  const generic = /(ju jingyi|jujingyi|จวีจิ้งอี|จิ้งอี|จวี|鞠婧祎|小鞠|kiku|kikuuu|ju jing yi|蜜橘)/i.test(q);
1337
2062
  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;
2063
+ const matcher = createCatalogMatcher(q);
2064
+ if (matcher.broad) {
2065
+
2066
+ } else {
2067
+ const filtered = photos.filter(p => matcher(p.titleTh, p.titleCn, p.titleEn, p.category, p.description));
2068
+ if (filtered.length > 0) {
2069
+ photos = filtered;
2070
+ usedQueryFilter = true;
2071
+ } else {
2072
+ return {
2073
+ status: "not_found",
2074
+ count: 0,
2075
+ photos: [],
2076
+ query,
2077
+ message: `No photos match the query "${query}" in the local catalog. Do NOT present other photos as an answer to this query. Use web_search or web_extract for external information, or call get_gallery_photos without a query to browse all photos.`
2078
+ };
2079
+ }
1342
2080
  }
1343
-
1344
2081
  }
1345
2082
  }
1346
2083
  if (year) photos = photos.filter(p => Number(p.era || p.year) === Number(year));
@@ -1354,8 +2091,15 @@ export const toolHandlers = {
1354
2091
  id: p.id || `photo-${p.era || p.year || 'unknown'}-${index + 1}`,
1355
2092
  title: p.titleTh || p.titleCn,
1356
2093
  category: p.category,
1357
- year: p.era || p.year
1358
- }))
2094
+ year: p.era || p.year,
2095
+ imageUrl: p.image || p.imageUrl || p.url || null
2096
+ })),
2097
+ images: shown.map(p => ({
2098
+ title: p.titleTh || p.titleCn || 'Gallery Photo',
2099
+ imageUrl: p.image || p.imageUrl || p.url,
2100
+ thumbUrl: p.image || p.imageUrl || p.url,
2101
+ source: 'Official Gallery'
2102
+ })).filter(img => img.imageUrl)
1359
2103
  };
1360
2104
  if (usedQueryFilter === false && query) {
1361
2105
  result.message = `Showing all ${result.count} photos (broad query "${query}" applied to full list).`;
@@ -1379,6 +2123,12 @@ export const toolHandlers = {
1379
2123
  matcher(s.titleTh, s.titleEn, s.titleCn, s.work, s.role, s.description)
1380
2124
  ).slice(0, 4).map(s => ({
1381
2125
  title: `${s.titleTh} (${s.year})`,
2126
+ titleEn: s.titleEn || null,
2127
+ titleCn: s.titleCn || null,
2128
+ year: s.year,
2129
+ work: s.work || null,
2130
+ role: s.role || null,
2131
+ description: s.description || null,
1382
2132
  youtubeUrl: s.youtubeUrl || null,
1383
2133
  isDirectLink: Boolean(s.youtubeUrl),
1384
2134
  youtubeSearchUrl: s.youtubeSearchUrl
@@ -1388,6 +2138,8 @@ export const toolHandlers = {
1388
2138
  matcher(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description)
1389
2139
  ).slice(0, 4).map(m => ({
1390
2140
  title: `${formatCatalogTitle(m.titleTh, m.titleCn)} [${m.category}]`,
2141
+ drama: m.dramaName || null,
2142
+ description: m.description || null,
1391
2143
  youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1392
2144
  isDirectLink: Boolean(m.youtubeId),
1393
2145
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh}`)
@@ -1409,7 +2161,8 @@ export const toolHandlers = {
1409
2161
  works: matchedWorks,
1410
2162
  songs: matchedSongs,
1411
2163
  brands: matchedBrands,
1412
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`)
2164
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`),
2165
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${query}`)
1413
2166
  }
1414
2167
  };
1415
2168
  },
@@ -1422,35 +2175,35 @@ export const toolHandlers = {
1422
2175
  return await fetchWebpage(url);
1423
2176
  },
1424
2177
 
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);
2178
+ 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);
1429
2182
  if (!originCheck.safe) {
1430
2183
  return { status: "error", message: `Unsafe API base URL: ${originCheck.reason}` };
1431
2184
  }
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,
2185
+ const allowedOrigins = new Set([
2186
+ 'https://jujingyi-kikuuu.vercel.app',
2187
+ ...(process.env.KIKU_API_ALLOWED_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean)
2188
+ ]);
2189
+ if (!originCheck.url || !allowedOrigins.has(originCheck.url.origin)) {
2190
+ return { status: 'error', message: 'API base origin is not allowlisted.' };
2191
+ }
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/.' };
2196
+ }
2197
+ if (query) target.searchParams.set('q', query);
2198
+
2199
+ try {
2200
+ const result = await fetchJson(target.href, { timeoutMs: 10000, maxResponseLength: 100000 });
2201
+ if (result.status !== 'success') return result;
2202
+ const json = result.data;
2203
+ const data = json?.data || json;
2204
+ return {
2205
+ status: "success",
2206
+ endpoint: path,
1454
2207
  count: Array.isArray(data) ? data.length : undefined,
1455
2208
  data
1456
2209
  };
@@ -1458,7 +2211,7 @@ export const toolHandlers = {
1458
2211
  return {
1459
2212
  status: "error",
1460
2213
  endpoint: path,
1461
- message: err.name === 'AbortError' ? 'API request timed out' : err.message
2214
+ message: err.name === 'AbortError' ? 'API request timed out' : err.message
1462
2215
  };
1463
2216
  }
1464
2217
  },
@@ -1467,24 +2220,62 @@ export const toolHandlers = {
1467
2220
  return await fetchJson(url, { method, headers, body });
1468
2221
  },
1469
2222
 
2223
+ search_news: async ({ query, maxResults } = {}) => {
2224
+ if (!query) return { status: 'error', message: 'query parameter is required.' };
2225
+ return await searchNews(query, maxResults);
2226
+ },
2227
+
2228
+ fetch_rss: async ({ url, maxItems } = {}) => {
2229
+ return await fetchRssFeed(url, maxItems);
2230
+ },
2231
+
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);
2236
+ },
2237
+
2238
+ search_music_platforms: async ({ term, maxResults } = {}) => {
2239
+ if (!term) return { status: 'error', message: 'term parameter is required.' };
2240
+ return await searchMusicPlatforms(term, maxResults);
2241
+ },
2242
+
2243
+ search_chinese_web: async ({ query, maxResults, site } = {}) => {
2244
+ 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 });
2247
+ },
2248
+
2249
+ search_bilibili: async ({ keyword, maxResults } = {}) => {
2250
+ 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);
2253
+ },
2254
+
2255
+ search_web_images: async ({ query, maxResults } = {}) => {
2256
+ 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);
2259
+ },
2260
+
1470
2261
  read_local_file: async ({ path: filePath, maxLines = 200 } = {}) => {
1471
2262
  if (!filePath) return { status: 'error', message: 'path parameter is required.' };
1472
2263
  try {
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);
2264
+ const projectRoot = await realpath(process.cwd());
2265
+ const resolved = resolve(projectRoot, filePath);
2266
+ const realResolved = await realpath(resolved);
2267
+ const relativePath = relative(projectRoot, realResolved);
2268
+
2269
+ if (relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
2270
+ return { status: 'error', message: 'Path must be within the project directory.' };
2271
+ }
2272
+
2273
+ if (/(^|[\\/])\.env(?:\.|$)/i.test(relativePath) || /(^|[\\/])cookies\.txt$/i.test(relativePath) ||
2274
+ /\.(pem|key|p12|pfx)$/i.test(relativePath) || /(^|[\\/])\.(git|npmrc|netrc|ssh)([\\/]|$)/i.test(relativePath)) {
2275
+ return { status: 'error', message: 'Reading this file is not permitted (sensitive file).' };
2276
+ }
2277
+
2278
+ const fileStat = await stat(realResolved);
1488
2279
  if (!fileStat.isFile()) {
1489
2280
  return { status: 'error', message: `"${filePath}" is not a file.` };
1490
2281
  }
@@ -1493,7 +2284,7 @@ export const toolHandlers = {
1493
2284
  return { status: 'error', message: `File too large (${Math.round(fileStat.size / 1024)}KB). Use maxLines to read a portion.` };
1494
2285
  }
1495
2286
 
1496
- const content = await readFile(realResolved, 'utf-8');
2287
+ const content = await readFile(realResolved, 'utf-8');
1497
2288
  const lines = content.split('\n');
1498
2289
  const totalLines = lines.length;
1499
2290
  const truncated = totalLines > maxLines;
@@ -1518,14 +2309,14 @@ export const toolHandlers = {
1518
2309
 
1519
2310
  list_directory: async ({ path: dirPath = '.', recursive = false } = {}) => {
1520
2311
  try {
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
- }
2312
+ const projectRoot = await realpath(process.cwd());
2313
+ const resolved = resolve(projectRoot, dirPath);
2314
+ const realResolved = await realpath(resolved);
2315
+ const relativePath = relative(projectRoot, realResolved);
2316
+
2317
+ if (relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
2318
+ return { status: 'error', message: 'Path must be within the project directory.' };
2319
+ }
1529
2320
 
1530
2321
  async function listDir(dir, depth = 0) {
1531
2322
  if (depth > (recursive ? 3 : 0)) return [];
@@ -1565,7 +2356,7 @@ export const toolHandlers = {
1565
2356
  return items;
1566
2357
  }
1567
2358
 
1568
- const items = await listDir(realResolved);
2359
+ const items = await listDir(realResolved);
1569
2360
  return {
1570
2361
  status: 'success',
1571
2362
  path: relativePath || '.',
@@ -1594,7 +2385,7 @@ export const toolHandlers = {
1594
2385
  const awards = Array.isArray(awardsData) ? awardsData : (awardsData?.awards || []);
1595
2386
  const timeline = Array.isArray(timelineData) ? timelineData : (timelineData?.eras || []);
1596
2387
  const mvs = getAllMusicVideos();
1597
- const gpu = await getGpuMetrics().catch(() => ({ available: false }));
2388
+ const gpu = await getGpuMetrics({ includeShared: false }).catch(() => ({ available: false }));
1598
2389
 
1599
2390
  const debutMatch = profileData.details?.debutDate?.match(/\b(19\d\d|20\d\d)\b/);
1600
2391
  const debutYear = debutMatch ? Number(debutMatch[1]) : 2013;
@@ -1920,6 +2711,127 @@ export const toolHandlers = {
1920
2711
  apiKey: null
1921
2712
  };
1922
2713
  },
2714
+
2715
+ get_entertainment_metrics: async ({ platform, category, query } = {}) => {
2716
+ let platforms = [...platformsCatalog];
2717
+ let benchmarks = [...(artistIndustryMetrics.dramaBenchmarks || [])];
2718
+
2719
+ if (platform && platform !== 'all') {
2720
+ const pId = String(platform).toLowerCase().trim();
2721
+ platforms = platforms.filter(p => p.id.toLowerCase() === pId);
2722
+ }
2723
+
2724
+ if (category && category !== 'all') {
2725
+ const cat = String(category).toLowerCase().trim();
2726
+ platforms = platforms.filter(p => p.category.toLowerCase() === cat);
2727
+ }
2728
+
2729
+ if (query) {
2730
+ const qLower = String(query).toLowerCase().trim();
2731
+ platforms = platforms.filter(p =>
2732
+ p.nameEn.toLowerCase().includes(qLower) ||
2733
+ p.nameCn.includes(qLower) ||
2734
+ (p.nameTh && p.nameTh.toLowerCase().includes(qLower)) ||
2735
+ (p.industryRoleTh && p.industryRoleTh.toLowerCase().includes(qLower)) ||
2736
+ (p.coreMetricsTh && p.coreMetricsTh.some(m => m.toLowerCase().includes(qLower))) ||
2737
+ (p.kikuHighlightsTh && p.kikuHighlightsTh.some(h => h.toLowerCase().includes(qLower)))
2738
+ );
2739
+ benchmarks = benchmarks.filter(b =>
2740
+ b.titleCn.includes(qLower) ||
2741
+ b.titleEn.toLowerCase().includes(qLower) ||
2742
+ (b.titleTh && b.titleTh.toLowerCase().includes(qLower)) ||
2743
+ (b.platform && b.platform.toLowerCase().includes(qLower)) ||
2744
+ (b.significanceTh && b.significanceTh.toLowerCase().includes(qLower))
2745
+ );
2746
+ }
2747
+
2748
+ return {
2749
+ status: "success",
2750
+ totalPlatforms: platforms.length,
2751
+ categoryFilter: category || 'all',
2752
+ platformFilter: platform || 'all',
2753
+ platforms,
2754
+ dramaBenchmarks: benchmarks,
2755
+ artistOverview: artistIndustryMetrics.artist,
2756
+ socialBuzzOverview: artistIndustryMetrics.socialBuzzOverview,
2757
+ message: `Found ${platforms.length} big data platforms across Chinese entertainment analytics.`
2758
+ };
2759
+ },
2760
+
2761
+ get_industry_index: async (args = {}, options = {}) => {
2762
+ return toolHandlers.get_entertainment_metrics(args, options);
2763
+ },
2764
+
2765
+ checkpoint_step: async ({ run_id, step_name, status, summary } = {}, options = {}) => {
2766
+ let memory = options?.agent?.memory || options?.memory;
2767
+ if (!memory) {
2768
+ const { AgentMemory } = await import('./memory.js');
2769
+ memory = new AgentMemory();
2770
+ await memory.load();
2771
+ }
2772
+ const result = await memory.checkpointStep({ runId: run_id, stepName: step_name, status, summary });
2773
+ return {
2774
+ status: "success",
2775
+ message: `Checkpoint recorded for run ${run_id}: step "${step_name}" is ${status}.`,
2776
+ checkpoint: result
2777
+ };
2778
+ },
2779
+
2780
+ record_failure: async ({ run_id, error_trace, recovery_action, tool_name } = {}, options = {}) => {
2781
+ let memory = options?.agent?.memory || options?.memory;
2782
+ if (!memory) {
2783
+ const { AgentMemory } = await import('./memory.js');
2784
+ memory = new AgentMemory();
2785
+ await memory.load();
2786
+ }
2787
+ const result = await memory.recordFailure({ runId: run_id, errorTrace: error_trace, recoveryAction: recovery_action, toolName: tool_name });
2788
+ return {
2789
+ status: "success",
2790
+ message: `Failure recorded for run ${run_id}.`,
2791
+ failure: result
2792
+ };
2793
+ },
2794
+
2795
+ load_active_run: async ({ run_id } = {}, options = {}) => {
2796
+ let memory = options?.agent?.memory || options?.memory;
2797
+ if (!memory) {
2798
+ const { AgentMemory } = await import('./memory.js');
2799
+ memory = new AgentMemory();
2800
+ await memory.load();
2801
+ }
2802
+ const result = await memory.loadActiveRun(run_id);
2803
+ if (!result || !result.exists) {
2804
+ return {
2805
+ status: "not_found",
2806
+ message: `Run "${run_id}" not found in active memory.`
2807
+ };
2808
+ }
2809
+ return {
2810
+ status: "success",
2811
+ run: result
2812
+ };
2813
+ },
2814
+
2815
+ record_dynamic_event: async ({ category, entry, source, trustTier } = {}) => {
2816
+ try {
2817
+ const added = addDynamicEntry(category, {
2818
+ ...(entry || {}),
2819
+ source: source || entry?.source || 'agent_inference',
2820
+ trustTier: trustTier || entry?.trustTier || 2,
2821
+ });
2822
+ globalToolCache.clear();
2823
+ return {
2824
+ status: "success",
2825
+ message: `Successfully recorded dynamic event in category "${category}".`,
2826
+ entry: added
2827
+ };
2828
+ } catch (err) {
2829
+ return {
2830
+ status: "error",
2831
+ message: `Failed to record dynamic event: ${err.message}`
2832
+ };
2833
+ }
2834
+ },
1923
2835
  };
1924
2836
 
1925
2837
  const toolSchemaByName = new Map(
@@ -2002,8 +2914,14 @@ export function prepareToolArguments(toolName, rawArgs = {}) {
2002
2914
  if (!checked.ok) {
2003
2915
  return { safe: false, args: {}, warnings, error: `Argument "${name}" must be ${propertySchema.type || 'valid'}.` };
2004
2916
  }
2005
- if (propertySchema.enum && !propertySchema.enum.includes(checked.value)) {
2006
- return { safe: false, args: {}, warnings, error: `Argument "${name}" must be one of: ${propertySchema.enum.join(', ')}.` };
2917
+ if (propertySchema.enum) {
2918
+ const target = String(checked.value).toLowerCase().trim();
2919
+ const matched = propertySchema.enum.find(e => String(e).toLowerCase() === target);
2920
+ if (matched !== undefined) {
2921
+ checked.value = matched;
2922
+ } else {
2923
+ return { safe: false, args: {}, warnings, error: `Argument "${name}" must be one of: ${propertySchema.enum.join(', ')}.` };
2924
+ }
2007
2925
  }
2008
2926
  if (checked.coerced) warnings.push(`Argument "${name}" was converted to ${propertySchema.type}`);
2009
2927
  validated[name] = checked.value;
@@ -2097,3 +3015,46 @@ export async function executeTool(toolName, args = {}, options = {}) {
2097
3015
  };
2098
3016
  }
2099
3017
  }
3018
+
3019
+ /**
3020
+ * Authoritative registry of primary tools with domain categorizations and schemas.
3021
+ */
3022
+ export const toolsRegistry = {
3023
+ global_search: {
3024
+ domain: 'core',
3025
+ isCore: true,
3026
+ schema: toolsSchema.find(t => t.function?.name === 'global_search')?.function,
3027
+ handler: toolHandlers.global_search,
3028
+ },
3029
+ get_awards: {
3030
+ domain: 'awards',
3031
+ isCore: false,
3032
+ schema: toolsSchema.find(t => t.function?.name === 'get_awards')?.function,
3033
+ handler: toolHandlers.get_awards,
3034
+ },
3035
+ get_gallery_photos: {
3036
+ domain: 'gallery',
3037
+ isCore: false,
3038
+ schema: toolsSchema.find(t => t.function?.name === 'get_gallery_photos')?.function,
3039
+ handler: toolHandlers.get_gallery_photos,
3040
+ },
3041
+ get_quotes: {
3042
+ domain: 'quotes',
3043
+ isCore: false,
3044
+ schema: toolsSchema.find(t => t.function?.name === 'get_quotes')?.function,
3045
+ handler: toolHandlers.get_quotes,
3046
+ },
3047
+ search_discography: {
3048
+ domain: 'discography',
3049
+ isCore: false,
3050
+ schema: toolsSchema.find(t => t.function?.name === 'search_discography')?.function,
3051
+ handler: toolHandlers.search_discography,
3052
+ },
3053
+ search_works: {
3054
+ domain: 'works',
3055
+ isCore: false,
3056
+ schema: toolsSchema.find(t => t.function?.name === 'search_works')?.function,
3057
+ handler: toolHandlers.search_works,
3058
+ },
3059
+ };
3060
+