kiku-agent 1.0.6 → 1.0.8

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.
@@ -0,0 +1,248 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import jpeg from 'jpeg-js';
5
+
6
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
7
+ export const DEFAULT_IMAGE_DIRECTORY = path.resolve(moduleDirectory, '../../src/assets/images');
8
+
9
+ const RESET = '\x1b[0m';
10
+ const foreground = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
11
+ const background = (r, g, b) => `\x1b[48;2;${r};${g};${b}m`;
12
+ const QUADRANT_GLYPHS = [
13
+ ' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛',
14
+ '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█',
15
+ ];
16
+
17
+ export function supportsTerminalImages({
18
+ env = process.env,
19
+ isTTY = process.stdout.isTTY,
20
+ } = {}) {
21
+ const setting = String(env.KIKU_TUI_IMAGES || '').trim().toLowerCase();
22
+ if (['0', 'false', 'off', 'no'].includes(setting)) return false;
23
+ if (['1', 'true', 'on', 'yes'].includes(setting)) return true;
24
+ if (!isTTY || env.NO_COLOR !== undefined || String(env.TERM || '').toLowerCase() === 'dumb') return false;
25
+ return true;
26
+ }
27
+
28
+ const clampChannel = value => Math.max(0, Math.min(255, Math.round(value)));
29
+
30
+ function sourceChannel(image, x, y, channel) {
31
+ const safeX = Math.max(0, Math.min(image.width - 1, x));
32
+ const safeY = Math.max(0, Math.min(image.height - 1, y));
33
+ return image.data[(safeY * image.width + safeX) * 4 + channel];
34
+ }
35
+
36
+ function sampleBilinear(image, x, y, targetWidth, targetHeight) {
37
+ const sourceX = ((x + 0.5) * image.width) / targetWidth - 0.5;
38
+ const sourceY = ((y + 0.5) * image.height) / targetHeight - 0.5;
39
+ const left = Math.floor(sourceX);
40
+ const top = Math.floor(sourceY);
41
+ const right = left + 1;
42
+ const bottom = top + 1;
43
+ const mixX = sourceX - left;
44
+ const mixY = sourceY - top;
45
+ const result = [];
46
+
47
+ for (let channel = 0; channel < 3; channel++) {
48
+ const topMix = sourceChannel(image, left, top, channel) * (1 - mixX)
49
+ + sourceChannel(image, right, top, channel) * mixX;
50
+ const bottomMix = sourceChannel(image, left, bottom, channel) * (1 - mixX)
51
+ + sourceChannel(image, right, bottom, channel) * mixX;
52
+ result.push(topMix * (1 - mixY) + bottomMix * mixY);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function resizeAndEnhance(image, targetWidth, targetHeight) {
58
+ const pixels = new Float32Array(targetWidth * targetHeight * 3);
59
+ for (let y = 0; y < targetHeight; y++) {
60
+ for (let x = 0; x < targetWidth; x++) {
61
+ const color = sampleBilinear(image, x, y, targetWidth, targetHeight);
62
+ const offset = (y * targetWidth + x) * 3;
63
+ pixels[offset] = color[0];
64
+ pixels[offset + 1] = color[1];
65
+ pixels[offset + 2] = color[2];
66
+ }
67
+ }
68
+
69
+ const enhanced = new Uint8Array(pixels.length);
70
+ const at = (x, y, channel) => {
71
+ const safeX = Math.max(0, Math.min(targetWidth - 1, x));
72
+ const safeY = Math.max(0, Math.min(targetHeight - 1, y));
73
+ return pixels[(safeY * targetWidth + safeX) * 3 + channel];
74
+ };
75
+
76
+ for (let y = 0; y < targetHeight; y++) {
77
+ for (let x = 0; x < targetWidth; x++) {
78
+ const offset = (y * targetWidth + x) * 3;
79
+ const sharpened = [0, 1, 2].map(channel => {
80
+ const center = at(x, y, channel);
81
+ const neighbourAverage = (
82
+ at(x - 1, y, channel) + at(x + 1, y, channel)
83
+ + at(x, y - 1, channel) + at(x, y + 1, channel)
84
+ ) / 4;
85
+ return center * 1.18 - neighbourAverage * 0.18;
86
+ });
87
+ const luminance = sharpened[0] * 0.2126 + sharpened[1] * 0.7152 + sharpened[2] * 0.0722;
88
+ for (let channel = 0; channel < 3; channel++) {
89
+ const saturated = luminance + (sharpened[channel] - luminance) * 1.06;
90
+ enhanced[offset + channel] = clampChannel((saturated - 128) * 1.03 + 128);
91
+ }
92
+ }
93
+ }
94
+ return enhanced;
95
+ }
96
+
97
+ function colorDistance(left, right) {
98
+ const red = left[0] - right[0];
99
+ const green = left[1] - right[1];
100
+ const blue = left[2] - right[2];
101
+ return red * red + green * green + blue * blue;
102
+ }
103
+
104
+ function averageColors(colors, fallback) {
105
+ if (colors.length === 0) return fallback;
106
+ const sum = colors.reduce((total, color) => [
107
+ total[0] + color[0],
108
+ total[1] + color[1],
109
+ total[2] + color[2],
110
+ ], [0, 0, 0]);
111
+ return sum.map(value => clampChannel(value / colors.length));
112
+ }
113
+
114
+ function encodeQuadrants(colors) {
115
+ let first = colors[0];
116
+ let second = colors[1];
117
+ let furthestDistance = -1;
118
+ for (let left = 0; left < colors.length; left++) {
119
+ for (let right = left + 1; right < colors.length; right++) {
120
+ const distance = colorDistance(colors[left], colors[right]);
121
+ if (distance > furthestDistance) {
122
+ furthestDistance = distance;
123
+ first = colors[left];
124
+ second = colors[right];
125
+ }
126
+ }
127
+ }
128
+
129
+ let assignments = [0, 0, 0, 0];
130
+ for (let iteration = 0; iteration < 3; iteration++) {
131
+ assignments = colors.map(color => colorDistance(color, second) < colorDistance(color, first) ? 1 : 0);
132
+ first = averageColors(colors.filter((_, index) => assignments[index] === 0), first);
133
+ second = averageColors(colors.filter((_, index) => assignments[index] === 1), second);
134
+ }
135
+
136
+ const mask = assignments.reduce((value, assignment, index) => value | (assignment << index), 0);
137
+ return {
138
+ glyph: QUADRANT_GLYPHS[mask],
139
+ foregroundColor: second,
140
+ backgroundColor: first,
141
+ };
142
+ }
143
+
144
+ export function renderJpegToAnsi(buffer, {
145
+ columns = process.stdout.columns || 80,
146
+ rows = process.stdout.rows || 24,
147
+ maxWidth = 60,
148
+ maxRows = 20,
149
+ indent = ' ',
150
+ } = {}) {
151
+ const image = jpeg.decode(buffer, { useTArray: true, formatAsRGBA: true });
152
+ if (!image?.width || !image?.height) throw new Error('Unable to decode gallery image');
153
+
154
+ const indentWidth = Array.from(indent).length;
155
+ const availableWidth = Math.max(8, Math.floor(columns) - indentWidth - 2);
156
+ let targetWidth = Math.max(8, Math.min(Math.floor(maxWidth), availableWidth, image.width));
157
+ const availableRows = Math.max(3, Math.min(Math.floor(maxRows), Math.floor(rows) - 7));
158
+ let targetPixelHeight = Math.max(2, Math.round((image.height / image.width) * targetWidth));
159
+ let targetRows = Math.ceil(targetPixelHeight / 2);
160
+
161
+ if (targetRows > availableRows) {
162
+ targetRows = availableRows;
163
+ targetPixelHeight = targetRows * 2;
164
+ targetWidth = Math.max(8, Math.min(targetWidth, Math.round((image.width / image.height) * targetPixelHeight)));
165
+ }
166
+
167
+ const sampledWidth = targetWidth * 2;
168
+ const resized = resizeAndEnhance(image, sampledWidth, targetPixelHeight);
169
+ const pixelAt = (x, y) => {
170
+ const offset = (y * sampledWidth + x) * 3;
171
+ return [resized[offset], resized[offset + 1], resized[offset + 2]];
172
+ };
173
+
174
+ const lines = [];
175
+ for (let row = 0; row < targetRows; row++) {
176
+ let line = indent;
177
+ const topY = Math.min(targetPixelHeight - 1, row * 2);
178
+ const bottomY = Math.min(targetPixelHeight - 1, row * 2 + 1);
179
+ for (let column = 0; column < targetWidth; column++) {
180
+ const leftX = column * 2;
181
+ const rightX = leftX + 1;
182
+ const cell = encodeQuadrants([
183
+ pixelAt(leftX, topY),
184
+ pixelAt(rightX, topY),
185
+ pixelAt(leftX, bottomY),
186
+ pixelAt(rightX, bottomY),
187
+ ]);
188
+ line += `${foreground(...cell.foregroundColor)}${background(...cell.backgroundColor)}${cell.glyph}`;
189
+ }
190
+ lines.push(`${line}${RESET}`);
191
+ }
192
+ return lines;
193
+ }
194
+
195
+ export function renderJpegFile(filePath, options = {}) {
196
+ return renderJpegToAnsi(readFileSync(filePath), options);
197
+ }
198
+
199
+ function discoverImages(directory) {
200
+ try {
201
+ return readdirSync(directory, { withFileTypes: true })
202
+ .filter(entry => entry.isFile() && /\.jpe?g$/i.test(entry.name))
203
+ .map(entry => path.join(directory, entry.name))
204
+ .sort((a, b) => a.localeCompare(b));
205
+ } catch {
206
+ return [];
207
+ }
208
+ }
209
+
210
+ export function createTerminalImageGallery({
211
+ directory = DEFAULT_IMAGE_DIRECTORY,
212
+ frequency = 3,
213
+ env = process.env,
214
+ isTTY = process.stdout.isTTY,
215
+ getColumns = () => process.stdout.columns || 80,
216
+ getRows = () => process.stdout.rows || 24,
217
+ } = {}) {
218
+ const images = discoverImages(directory);
219
+ const enabled = images.length > 0 && supportsTerminalImages({ env, isTTY });
220
+ let imageIndex = 0;
221
+
222
+ return {
223
+ enabled,
224
+ imageCount: images.length,
225
+ maybeRender(completedQueries = 0, { force = false } = {}) {
226
+ const cadence = Math.max(1, Math.floor(frequency));
227
+ if (!enabled || (!force && (completedQueries <= 0 || completedQueries % cadence !== 0))) return [];
228
+
229
+ const currentIndex = imageIndex % images.length;
230
+ const filePath = images[currentIndex];
231
+ try {
232
+ const imageLines = renderJpegFile(filePath, {
233
+ columns: getColumns(),
234
+ rows: getRows(),
235
+ });
236
+ imageIndex++;
237
+ return [
238
+ '',
239
+ ` \x1b[1m\x1b[38;2;255;126;179m◆ KIKU MOMENT\x1b[0m \x1b[38;2;148;163;184m• Gallery ${currentIndex + 1}/${images.length}\x1b[0m`,
240
+ ...imageLines,
241
+ '',
242
+ ];
243
+ } catch {
244
+ return [];
245
+ }
246
+ },
247
+ };
248
+ }
@@ -30,9 +30,16 @@ export function buildQqMusicSearchUrl(query) {
30
30
  return `https://y.qq.com/n/ryqq/search?w=${encodeURIComponent(query)}`;
31
31
  }
32
32
 
33
- export function buildYoukuSearchUrl(query) {
34
- return `https://so.youku.com/search_video/q_${encodeURIComponent(query)}`;
35
- }
33
+ export function buildYoukuSearchUrl(query) {
34
+ return `https://so.youku.com/search_video/q_${encodeURIComponent(query)}`;
35
+ }
36
+
37
+ export function formatCatalogTitle(primary, secondary) {
38
+ const first = String(primary || secondary || '').trim();
39
+ const second = String(secondary || '').trim();
40
+ if (!second || first.toLocaleLowerCase().includes(second.toLocaleLowerCase())) return first;
41
+ return `${first} (${second})`;
42
+ }
36
43
 
37
44
  export class ToolCache {
38
45
  constructor(maxSize = 300, defaultTtlMs = 600_000) {
@@ -302,9 +309,11 @@ export const toolsSchema = [
302
309
  description: "Query awards and honors received by Ju Jingyi across music, television, and cultural influence.",
303
310
  parameters: {
304
311
  type: "object",
305
- properties: {
306
- category: { type: "string", description: "Category (Acting, Music, Honor)" },
307
- year: { type: "number", description: "Year filter" }
312
+ properties: {
313
+ category: { type: "string", description: "Category (Acting, Music, Honor)" },
314
+ year: { type: "number", description: "Year filter" },
315
+ query: { type: "string", description: "Award title, ceremony, or category keyword" },
316
+ limit: { type: "number", description: "Maximum results from 1 to 20" }
308
317
  },
309
318
  required: []
310
319
  }
@@ -332,9 +341,11 @@ export const toolsSchema = [
332
341
  description: "Search visual photo gallery of Ju Jingyi (red carpet, photoshoot, drama stills).",
333
342
  parameters: {
334
343
  type: "object",
335
- properties: {
336
- category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
337
- limit: { type: "number", description: "Number of photos (default 4)" }
344
+ properties: {
345
+ category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
346
+ query: { type: "string", description: "Photo title or category keyword" },
347
+ year: { type: "number", description: "Year filter" },
348
+ limit: { type: "number", description: "Number of photos from 1 to 20" }
338
349
  },
339
350
  required: []
340
351
  }
@@ -726,6 +737,30 @@ export const toolsSchema = [
726
737
  required: ["action"]
727
738
  }
728
739
  }
740
+ },
741
+ {
742
+ type: "function",
743
+ function: {
744
+ name: "run_quiz",
745
+ description: "Fetch the Ju Jingyi quiz challenge session and return all 15 questions with shuffled options for the user to answer interactively via /quiz command.",
746
+ parameters: {
747
+ type: "object",
748
+ properties: {
749
+ lang: { type: "string", enum: ["th", "en", "zh"], description: "Language for quiz questions (default: auto-detect from user preference)." }
750
+ }
751
+ }
752
+ }
753
+ },
754
+ {
755
+ type: "function",
756
+ function: {
757
+ name: "get_api_key",
758
+ description: "Retrieve the stored Developer VIP API key for authenticated requests to the Ju Jingyi API.",
759
+ parameters: {
760
+ type: "object",
761
+ properties: {}
762
+ }
763
+ }
729
764
  }
730
765
  ];
731
766
 
@@ -829,7 +864,7 @@ export const toolHandlers = {
829
864
  youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
830
865
  youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
831
866
  themeSong: w.themeSong ? {
832
- title: `${w.themeSong.titleTh || ''} (${w.themeSong.titleCn || ''})`,
867
+ title: formatCatalogTitle(w.themeSong.titleTh, w.themeSong.titleCn),
833
868
  youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
834
869
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
835
870
  } : null
@@ -875,7 +910,7 @@ export const toolHandlers = {
875
910
  synopsis: work.synopsis?.slice(0, 250) + (work.synopsis?.length > 250 ? '...' : ''),
876
911
  highlights: (work.highlights || []).slice(0, 3),
877
912
  themeSong: work.themeSong ? {
878
- title: `${work.themeSong.titleTh || ''} (${work.themeSong.titleCn || ''})`,
913
+ title: formatCatalogTitle(work.themeSong.titleTh, work.themeSong.titleCn),
879
914
  youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
880
915
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${work.themeSong.titleCn || work.themeSong.titleTh} OST`)
881
916
  } : null
@@ -885,7 +920,7 @@ export const toolHandlers = {
885
920
 
886
921
  get_character_roles: async ({ query } = {}) => {
887
922
  const roles = worksData.map(w => ({
888
- workTitle: `${w.titleTh} (${w.titleCn})`,
923
+ workTitle: formatCatalogTitle(w.titleTh, w.titleCn),
889
924
  year: w.year,
890
925
  role: w.role,
891
926
  roleCn: w.roleCn,
@@ -940,7 +975,7 @@ export const toolHandlers = {
940
975
  count: list.length,
941
976
  songs: list.slice(0, 8).map(s => ({
942
977
  id: s.id,
943
- title: `${s.titleTh} (${s.titleCn})`,
978
+ title: formatCatalogTitle(s.titleTh, s.titleCn),
944
979
  titleEn: s.titleEn,
945
980
  year: s.year,
946
981
  type: s.work,
@@ -991,7 +1026,7 @@ export const toolHandlers = {
991
1026
  const searchTitle = `Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage Live' : 'Official MV'}`;
992
1027
  return {
993
1028
  id: m.id,
994
- title: `${m.titleTh} (${m.titleCn})`,
1029
+ title: formatCatalogTitle(m.titleTh, m.titleCn),
995
1030
  category: m.category,
996
1031
  year: m.releaseYear || m.year,
997
1032
  drama: m.dramaName || null,
@@ -1021,7 +1056,7 @@ export const toolHandlers = {
1021
1056
  (m.dramaName && m.dramaName.toLowerCase().includes(q))
1022
1057
  ).map(m => ({
1023
1058
  type: m.category || 'MV',
1024
- title: `${m.titleTh} (${m.titleCn})`,
1059
+ title: formatCatalogTitle(m.titleTh, m.titleCn),
1025
1060
  year: m.releaseYear || m.year,
1026
1061
  duration: m.duration,
1027
1062
  youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
@@ -1036,7 +1071,7 @@ export const toolHandlers = {
1036
1071
  (s.work && s.work.toLowerCase().includes(q))
1037
1072
  ).map(s => ({
1038
1073
  type: s.work || 'Song',
1039
- title: `${s.titleTh} (${s.titleCn})`,
1074
+ title: formatCatalogTitle(s.titleTh, s.titleCn),
1040
1075
  year: s.year,
1041
1076
  duration: s.duration,
1042
1077
  youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
@@ -1051,14 +1086,14 @@ export const toolHandlers = {
1051
1086
  (w.titleEn && w.titleEn.toLowerCase().includes(q))
1052
1087
  ).map(w => ({
1053
1088
  type: 'Drama / Series',
1054
- title: `${w.titleTh} (${w.titleCn})`,
1089
+ title: formatCatalogTitle(w.titleTh, w.titleCn),
1055
1090
  year: w.year,
1056
1091
  platform: w.streamPlatform,
1057
1092
  streamUrl: w.streamUrl || null,
1058
1093
  youtubeTrailerSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer Official`),
1059
1094
  youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
1060
1095
  themeSong: w.themeSong ? {
1061
- title: `${w.themeSong.titleTh || ''} (${w.themeSong.titleCn || ''})`,
1096
+ title: formatCatalogTitle(w.themeSong.titleTh, w.themeSong.titleCn),
1062
1097
  youtubeUrl: w.themeSong.youtubeId ? buildYouTubeWatchUrl(w.themeSong.youtubeId) : null,
1063
1098
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
1064
1099
  } : null
@@ -1135,26 +1170,32 @@ export const toolHandlers = {
1135
1170
  count: eras.length,
1136
1171
  milestones: eras.slice(0, 6).map(e => ({
1137
1172
  period: e.period || e.year,
1138
- title: `${e.title} (${e.titleCn || ''})`,
1173
+ title: formatCatalogTitle(e.title, e.titleCn),
1139
1174
  summary: e.summary?.slice(0, 150),
1140
1175
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${e.titleCn || e.title}`)
1141
1176
  }))
1142
1177
  };
1143
1178
  },
1144
1179
 
1145
- get_awards: async ({ category, year } = {}) => {
1146
- let list = Array.isArray(awardsData) ? awardsData : (awardsData.awards || []);
1147
- if (year) list = list.filter(a => a.year === Number(year));
1148
- if (category && category !== 'all') list = list.filter(a => a.category?.toLowerCase().includes(category.toLowerCase()));
1149
-
1150
- return {
1151
- status: "success",
1152
- count: list.length,
1153
- awards: list.slice(0, 8).map(a => ({
1154
- year: a.year,
1155
- title: `${a.titleTh} (${a.titleCn})`,
1156
- ceremony: a.ceremony,
1157
- category: a.category,
1180
+ get_awards: async ({ category, year, query, limit = 8 } = {}) => {
1181
+ let list = Array.isArray(awardsData) ? awardsData : (awardsData.awards || []);
1182
+ if (year) list = list.filter(a => a.year === Number(year));
1183
+ if (category && category !== 'all') list = list.filter(a => a.category?.toLowerCase().includes(category.toLowerCase()));
1184
+ if (query) {
1185
+ const q = String(query).toLowerCase().trim();
1186
+ list = list.filter(a => [a.titleTh, a.titleCn, a.titleEn, a.ceremony, a.category].filter(Boolean).join(' ').toLowerCase().includes(q));
1187
+ }
1188
+ const safeLimit = Math.min(Math.max(Number(limit) || 8, 1), 20);
1189
+
1190
+ return {
1191
+ status: "success",
1192
+ count: list.length,
1193
+ awards: list.slice(0, safeLimit).map((a, index) => ({
1194
+ id: a.id || `award-${a.year || 'unknown'}-${index + 1}`,
1195
+ year: a.year,
1196
+ title: `${a.titleTh || a.titleEn || a.titleCn || 'Award'}${a.titleCn && a.titleCn !== a.titleTh ? ` (${a.titleCn})` : ''}`,
1197
+ ceremony: a.ceremony,
1198
+ category: a.category,
1158
1199
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${a.titleCn || a.ceremony}`)
1159
1200
  }))
1160
1201
  };
@@ -1176,15 +1217,22 @@ export const toolHandlers = {
1176
1217
  };
1177
1218
  },
1178
1219
 
1179
- get_gallery_photos: async ({ category, limit = 4 } = {}) => {
1180
- let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
1181
- if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1182
-
1183
- return {
1184
- status: "success",
1185
- count: Math.min(photos.length, Number(limit) || 4),
1186
- photos: photos.slice(0, Number(limit) || 4).map(p => ({
1187
- title: p.titleTh || p.titleCn,
1220
+ get_gallery_photos: async ({ category, query, year, limit = 4 } = {}) => {
1221
+ let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
1222
+ if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1223
+ if (query) {
1224
+ const q = String(query).toLowerCase().trim();
1225
+ photos = photos.filter(p => [p.titleTh, p.titleCn, p.titleEn, p.category, p.description].filter(Boolean).join(' ').toLowerCase().includes(q));
1226
+ }
1227
+ if (year) photos = photos.filter(p => Number(p.era || p.year) === Number(year));
1228
+ const safeLimit = Math.min(Math.max(Number(limit) || 4, 1), 20);
1229
+
1230
+ return {
1231
+ status: "success",
1232
+ count: Math.min(photos.length, safeLimit),
1233
+ photos: photos.slice(0, safeLimit).map((p, index) => ({
1234
+ id: p.id || `photo-${p.era || p.year || 'unknown'}-${index + 1}`,
1235
+ title: p.titleTh || p.titleCn,
1188
1236
  category: p.category,
1189
1237
  year: p.era || p.year
1190
1238
  }))
@@ -1220,7 +1268,7 @@ export const toolHandlers = {
1220
1268
  const mvs = getAllMusicVideos().filter(m =>
1221
1269
  matchesQuery(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description)
1222
1270
  ).slice(0, 4).map(m => ({
1223
- title: `${m.titleTh} (${m.titleCn}) [${m.category}]`,
1271
+ title: `${formatCatalogTitle(m.titleTh, m.titleCn)} [${m.category}]`,
1224
1272
  youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1225
1273
  youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh}`)
1226
1274
  }));
@@ -1616,7 +1664,69 @@ export const toolHandlers = {
1616
1664
  };
1617
1665
  }
1618
1666
  }
1619
- }
1667
+ },
1668
+
1669
+ run_quiz: async ({ lang } = {}, options = {}) => {
1670
+ let memory = options?.agent?.memory || options?.memory;
1671
+ if (!memory) {
1672
+ const { AgentMemory } = await import('./memory.js');
1673
+ memory = new AgentMemory();
1674
+ await memory.load();
1675
+ }
1676
+ const quizLang = ['th', 'en', 'zh'].includes(lang) ? lang : (memory.preferences?.language || 'en');
1677
+ const { shuffleQuizQuestions } = await import('./quizData.js');
1678
+ const { QUIZ_QUESTIONS, QUIZ_I18N, LETTER_LABELS } = await import('./quizData.js');
1679
+ const i18n = QUIZ_I18N[quizLang] || QUIZ_I18N.en;
1680
+ const shuffled = shuffleQuizQuestions(QUIZ_QUESTIONS);
1681
+
1682
+ let challengeToken = null;
1683
+ try {
1684
+ const baseUrl = options?.baseUrl || 'http://localhost:3000';
1685
+ const res = await fetch(`${baseUrl}/api/v1/quiz/challenge`);
1686
+ if (res.ok) {
1687
+ const json = await res.json();
1688
+ challengeToken = json.data?.challengeToken;
1689
+ }
1690
+ } catch {}
1691
+
1692
+ return {
1693
+ status: 'success',
1694
+ message: i18n.subtitle,
1695
+ lang: quizLang,
1696
+ challengeToken,
1697
+ instructions: 'Present each question one by one. User answers with A/B/C/D or 1-4. After all 15, use POST /api/v1/keys/claim with challengeToken and answers array [{qIndex, answerIndex}].',
1698
+ questions: shuffled.map((q, i) => ({
1699
+ index: i,
1700
+ question: q.q[quizLang] || q.q.en,
1701
+ options: (q.options[quizLang] || q.options.en).map((opt, idx) => `${LETTER_LABELS[idx]}) ${opt}`),
1702
+ hint: q.hint[quizLang] || q.hint.en,
1703
+ }))
1704
+ };
1705
+ },
1706
+
1707
+ get_api_key: async (_args = {}, options = {}) => {
1708
+ let memory = options?.agent?.memory || options?.memory;
1709
+ if (!memory) {
1710
+ const { AgentMemory } = await import('./memory.js');
1711
+ memory = new AgentMemory();
1712
+ await memory.load();
1713
+ }
1714
+ const key = memory.preferences?.vipKey;
1715
+ if (key) {
1716
+ return {
1717
+ status: 'success',
1718
+ apiKey: key,
1719
+ tier: 'Developer VIP',
1720
+ rateLimit: '1,000 requests/minute',
1721
+ usage: `Header: X-API-Key: ${key} | Query: ?apiKey=${key}`
1722
+ };
1723
+ }
1724
+ return {
1725
+ status: 'info',
1726
+ message: 'No VIP key stored. Run /quiz in the CLI to complete the challenge and claim one.',
1727
+ apiKey: null
1728
+ };
1729
+ },
1620
1730
  };
1621
1731
 
1622
1732
  const toolSchemaByName = new Map(