kiku-agent 1.0.7 → 1.0.9

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.
@@ -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(
@@ -228,13 +228,28 @@ export function wrapText(text, maxWidth = 72) {
228
228
  const lines = [];
229
229
  const rawParagraphs = text.split('\n');
230
230
 
231
+ const tokenize = para => {
232
+ const raw = para.split(/\s+/).filter(Boolean);
233
+ const tokens = [];
234
+ for (let i = 0; i < raw.length; i++) {
235
+ if (raw[i] === '•' && i + 1 < raw.length) {
236
+ tokens.push('• ' + raw[i + 1]);
237
+ i++;
238
+ } else {
239
+ tokens.push(raw[i]);
240
+ }
241
+ }
242
+ return tokens;
243
+ };
244
+
231
245
  for (const para of rawParagraphs) {
232
246
  if (!para.trim()) {
233
247
  lines.push('');
234
248
  continue;
235
249
  }
236
250
 
237
- const words = para.split(/\s+/);
251
+ const words = tokenize(para);
252
+ const paraLines = [];
238
253
  let currentLine = '';
239
254
 
240
255
  for (const word of words) {
@@ -246,7 +261,7 @@ export function wrapText(text, maxWidth = 72) {
246
261
  currentLine = testLine;
247
262
  } else {
248
263
  if (currentLine) {
249
- lines.push(currentLine);
264
+ paraLines.push(currentLine);
250
265
  currentLine = word;
251
266
  } else {
252
267
  let chunk = '';
@@ -254,7 +269,7 @@ export function wrapText(text, maxWidth = 72) {
254
269
  if (getStringDisplayWidth(chunk + ch) <= maxWidth) {
255
270
  chunk += ch;
256
271
  } else {
257
- lines.push(chunk);
272
+ paraLines.push(chunk);
258
273
  chunk = ch;
259
274
  }
260
275
  }
@@ -262,9 +277,19 @@ export function wrapText(text, maxWidth = 72) {
262
277
  }
263
278
  }
264
279
  }
265
- if (currentLine) {
266
- lines.push(currentLine);
280
+ if (currentLine) paraLines.push(currentLine);
281
+
282
+ if (paraLines.length >= 2 && !/\s/.test(paraLines[paraLines.length - 1].trim())) {
283
+ const last = paraLines[paraLines.length - 1];
284
+ const prev = paraLines[paraLines.length - 2];
285
+ const candidate = prev + ' ' + last;
286
+ if (getStringDisplayWidth(candidate) <= maxWidth) {
287
+ paraLines[paraLines.length - 2] = candidate;
288
+ paraLines.pop();
289
+ }
267
290
  }
291
+
292
+ lines.push(...paraLines);
268
293
  }
269
294
  return lines;
270
295
  }
@@ -829,7 +854,7 @@ export function formatMarkdownLines(text, maxWidth = getFullWidth() - 8) {
829
854
  return result;
830
855
  }
831
856
 
832
- export function getWelcomeHeroLines() {
857
+ export function getWelcomeHeroLines() {
833
858
  const p = C.sakura;
834
859
  const r = C.roseGlow;
835
860
  const g = C.gold;
@@ -841,7 +866,8 @@ export function getWelcomeHeroLines() {
841
866
  const b = C.bold;
842
867
  const d = C.dim;
843
868
  const rst = C.reset;
844
- const boxWidth = Math.min(84, Math.max(50, getFullWidth() - 4));
869
+ const boxWidth = Math.min(84, Math.max(50, getFullWidth() - 4));
870
+ const compact = getFullWidth() < 120;
845
871
  const headerTitle = 'Suggested Topics & Queries';
846
872
  const topDashCount = Math.max(2, boxWidth - getStringDisplayWidth(headerTitle) - 6);
847
873
  const innerWidth = boxWidth - 2;
@@ -854,14 +880,28 @@ export function getWelcomeHeroLines() {
854
880
  return ` ${s}│${rst} ${icon} ${tag}${rst}${' '.repeat(Math.max(0, tagWidth - getStringDisplayWidth(tag)))} ${w}${text}${rst}${' '.repeat(pad)}${s}│${rst}`;
855
881
  };
856
882
 
857
- return [
858
- '',
859
- ` ${p}██╗ ██╗██╗██╗ ██╗██╗ ██╗ █████╗ ██╗${rst} ${b}${p}◆ Ju Jingyi • 鞠婧祎 • Mìjú Terminal Portal ◆${rst}`,
860
- ` ${p}██║ ██╔╝██║██║ ██╔╝██║ ██║ ██╔══██╗██║${rst} ${d}${s}Interactive Fan Knowledge Base & Autonomous Agent${rst}`,
861
- ` ${r}█████═╝ ██║█████═╝ ██║ ██║ ███████║██║${rst} ${d}${s}Filmography • Discography • Fashion & Awards${rst}`,
862
- ` ${r}██╔═██╗ ██║██╔═██╗ ██║ ██║ ██╔══██╗██║${rst} ${c}● Live Ready${rst} ${s}• Multilingual (CN / EN / TH)${rst}`,
863
- ` ${p}██║ ╚██╗██║██║ ╚██╗╚██████╔╝ ██║ ██║██║${rst}`,
864
- ` ${p}╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝${rst}`,
883
+ const heroLines = compact ? [
884
+ ` ${b}${p}◆ Ju Jingyi • 鞠婧祎 • Mìjú Terminal Portal ◆${rst}`,
885
+ ` ${p}██╗ ██╗██╗██╗ ██╗██╗ ██╗ █████╗ ██╗${rst}`,
886
+ ` ${r}█████═╝ ██║█████═╝ ██║ ██║ ███████║██║${rst}`,
887
+ ] : [
888
+ ` ${p}██╗ ██╗██╗██╗ ██╗██╗ ██╗ █████╗ ██╗${rst} ${b}${p}◆ Ju Jingyi • 鞠婧祎 • Mìjú Terminal Portal ◆${rst}`,
889
+ ` ${p}██║ ██╔╝██║██║ ██╔╝██║ ██║ ██╔══██╗██║${rst} ${d}${s}Interactive Fan Knowledge Base & Autonomous Agent${rst}`,
890
+ ` ${r}█████═╝ ██║█████═╝ ██║ ██║ ███████║██║${rst} ${d}${s}Filmography • Discography • Fashion & Awards${rst}`,
891
+ ` ${r}██╔═██╗ ██║██╔═██╗ ██║ ██║ ██╔══██╗██║${rst} ${c}● Live Ready${rst} ${s}• Multilingual (CN / EN / TH)${rst}`,
892
+ ` ${p}██║ ╚██╗██║██║ ╚██╗╚██████╔╝ ██║ ██║██║${rst}`,
893
+ ` ${p}╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝${rst}`,
894
+ ];
895
+ const shortcutLines = compact ? [
896
+ ` ${s}Shortcuts: ${b}${w}[Tab]${rst} ${s}Commands • ${b}${w}[↑/↓]${rst} ${s}History • ${b}${w}[F2]${rst} ${s}Details${rst}`,
897
+ ` ${b}${w}[PgUp/PgDn]${rst} ${s}Scroll • ${b}${w}[Ctrl+C]${rst} ${s}Exit${rst}`,
898
+ ] : [
899
+ ` ${s}Shortcuts: ${b}${w}[Tab]${rst} ${s}Autocomplete • ${b}${w}[↑/↓]${rst} ${s}History • ${b}${w}[PgUp/PgDn]${rst} ${s}Scroll • ${b}${w}[F2]${rst} ${s}Details • ${b}${w}[Ctrl+C]${rst} ${s}Exit${rst}`,
900
+ ];
901
+
902
+ return [
903
+ ...(compact ? [] : ['']),
904
+ ...heroLines,
865
905
  '',
866
906
  ` ${s}╭──${rst} ${b}${g}${headerTitle}${rst} ${s}${'─'.repeat(topDashCount)}╮${rst}`,
867
907
  padItem(p + '◆', '2026 Works', '"What are Ju Jingyi\'s 2026 upcoming dramas?"'),
@@ -870,7 +910,7 @@ export function getWelcomeHeroLines() {
870
910
  padItem(l + '◆', 'Tools / MCP', '"@search_works 2026, /mcp list, /stats, /help"'),
871
911
  ` ${s}╰${'─'.repeat(boxWidth)}╯${rst}`,
872
912
  '',
873
- ` ${s}Shortcuts: ${b}${w}[Tab]${rst} ${s}Autocomplete • ${b}${w}[↑/↓]${rst} ${s}History • ${b}${w}[PgUp/PgDn]${rst} ${s}Scroll • ${b}${w}[F2]${rst} ${s}Details • ${b}${w}[Ctrl+C]${rst} ${s}Exit${rst}`,
913
+ ...shortcutLines,
874
914
  '',
875
915
  ];
876
916
  }
@@ -1040,7 +1080,7 @@ export function exitAlternateScreen() {
1040
1080
  }
1041
1081
  }
1042
1082
 
1043
- export function getTuiViewportLayout(termRows = 24) {
1083
+ export function getTuiViewportLayout(termRows = 24) {
1044
1084
  const parsedRows = Number(termRows);
1045
1085
  const rows = Math.max(10, Number.isFinite(parsedRows) ? Math.floor(parsedRows) : 24);
1046
1086
  const viewportStartRow = 1;
@@ -1055,10 +1095,16 @@ export function getTuiViewportLayout(termRows = 24) {
1055
1095
  statusBarRow,
1056
1096
  promptRow,
1057
1097
  separatorRow,
1058
- };
1059
- }
1060
-
1061
- export class VirtualScreenBuffer {
1098
+ };
1099
+ }
1100
+
1101
+ export function getNewOutputScrollOffset(addedLineCount, termRows = 24, currentOffset = 0) {
1102
+ const added = Math.max(0, Number.isFinite(Number(addedLineCount)) ? Math.floor(Number(addedLineCount)) : 0);
1103
+ if (currentOffset > 0) return currentOffset + added;
1104
+ return Math.max(0, added - getTuiViewportLayout(termRows).viewportHeight);
1105
+ }
1106
+
1107
+ export class VirtualScreenBuffer {
1062
1108
  constructor(width = 80, height = 24, writer = null) {
1063
1109
  this.width = Math.max(10, width);
1064
1110
  this.height = Math.max(5, height);
@@ -24,11 +24,14 @@ export class UpdateError extends Error {
24
24
  }
25
25
 
26
26
  function cleanOutput(value, maxLength = 400) {
27
- return String(value || '')
27
+ const text = String(value || '')
28
28
  .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g, '')
29
29
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
30
- .trim()
31
- .slice(0, maxLength);
30
+ .split('\n')
31
+ .map(line => line.replace(/^npm\s+(?:error|ERR!)\s*/i, '').trim())
32
+ .filter(Boolean)
33
+ .join(' ');
34
+ return text.slice(0, maxLength);
32
35
  }
33
36
 
34
37
  function parseSemver(value) {
@@ -281,6 +284,21 @@ export class UpdateManager {
281
284
  const spec = `${this.packageName}@${normalizedTarget}`;
282
285
  const result = await this.runner(['view', spec, '--json', '--prefer-online'], { timeoutMs: DEFAULT_TIMEOUT_MS });
283
286
  if (result.code !== 0) {
287
+ const errText = String(result.stderr || '');
288
+ if (/E404|404\s+No match found|No match found for version|not found|404\s+Not\s+Found/i.test(errText)) {
289
+ if (UPDATE_CHANNELS.includes(normalizedTarget)) {
290
+ throw new UpdateError(
291
+ `No release is currently available for channel "${normalizedTarget}" on npm. (Only "latest" is currently published).`,
292
+ 'CHANNEL_NOT_FOUND',
293
+ { target: normalizedTarget, exitCode: result.code }
294
+ );
295
+ }
296
+ throw new UpdateError(
297
+ `Version or tag "${normalizedTarget}" was not found on the npm registry.`,
298
+ 'RELEASE_NOT_FOUND',
299
+ { target: normalizedTarget, exitCode: result.code }
300
+ );
301
+ }
284
302
  throw new UpdateError(
285
303
  cleanOutput(result.stderr) || 'npm could not find the requested release.',
286
304
  'REGISTRY_LOOKUP_FAILED',