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.
- package/LICENSE +621 -0
- package/README.md +296 -14
- package/package.json +95 -92
- package/server/agent/agentCore.js +58 -31
- package/server/agent/cli.js +79 -43
- package/server/agent/commandFeatures.js +517 -0
- package/server/agent/commandInput.js +113 -0
- package/server/agent/commandRegistry.js +122 -8
- package/server/agent/commands.js +403 -28
- package/server/agent/jobManager.js +96 -0
- package/server/agent/mcpClient.js +68 -11
- package/server/agent/memory.js +87 -14
- package/server/agent/quizData.js +384 -0
- package/server/agent/terminalImage.js +248 -0
- package/server/agent/tools.js +153 -43
- package/server/agent/ui.js +67 -21
- package/server/agent/updateManager.js +21 -3
- package/src/assets/images/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
- package/src/assets/images/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { executeTool } from './tools.js';
|
|
2
|
+
import { allowlistedValue, boundedInteger, parseCommandInput } from './commandInput.js';
|
|
3
|
+
import { C, printSolidPanel, sanitizeTerminalText } from './ui.js';
|
|
4
|
+
|
|
5
|
+
const ENTITY_TYPES = ['all', 'work', 'song', 'role', 'brand', 'award', 'photo', 'stage'];
|
|
6
|
+
const FAVORITE_TYPES = ENTITY_TYPES.filter(type => type !== 'all');
|
|
7
|
+
const TYPE_ALIASES = Object.freeze({
|
|
8
|
+
drama: 'work', series: 'work', movie: 'work', works: 'work',
|
|
9
|
+
music: 'song', songs: 'song', ost: 'song',
|
|
10
|
+
character: 'role', characters: 'role',
|
|
11
|
+
brands: 'brand', awards: 'award', photos: 'photo', gallery: 'photo',
|
|
12
|
+
performance: 'stage', performances: 'stage', mv: 'stage',
|
|
13
|
+
});
|
|
14
|
+
const OFFICIAL_NEWS_HOSTS = new Set([
|
|
15
|
+
'weibo.com', 'www.weibo.com', 'm.weibo.cn', 'bilibili.com', 'www.bilibili.com',
|
|
16
|
+
'youtube.com', 'www.youtube.com', 'instagram.com', 'www.instagram.com',
|
|
17
|
+
]);
|
|
18
|
+
const OFFICIAL_NEWS_SIGNAL = /\b(?:official|studio)\b|工作室|官方/i;
|
|
19
|
+
|
|
20
|
+
function parsedRequest(ctx) {
|
|
21
|
+
return ctx.parsed || parseCommandInput(ctx.rawInput || '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeType(value, fallback = 'all') {
|
|
25
|
+
const normalized = TYPE_ALIASES[String(value || '').toLowerCase()] || String(value || '').toLowerCase();
|
|
26
|
+
return allowlistedValue(normalized, ENTITY_TYPES, fallback);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clean(value, limit = 220) {
|
|
30
|
+
return sanitizeTerminalText(String(value ?? ''), limit);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printMessage(title, message, color = C.amber) {
|
|
34
|
+
printSolidPanel({ title, lines: [`${color}${clean(message, 500)}${C.reset}`] });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entityTitle(type, item) {
|
|
38
|
+
if (type === 'work') {
|
|
39
|
+
const primary = item.titleTh || item.titleEn || item.titleCn;
|
|
40
|
+
const secondary = item.titleEn && item.titleEn !== primary ? item.titleEn : item.titleCn && item.titleCn !== primary ? item.titleCn : '';
|
|
41
|
+
return secondary && !String(primary || '').toLowerCase().includes(String(secondary).toLowerCase()) ? `${primary} (${secondary})` : primary || 'Untitled work';
|
|
42
|
+
}
|
|
43
|
+
if (type === 'role') return item.roleCn ? `${item.role || item.roleCn} (${item.roleCn})` : item.role || 'Untitled role';
|
|
44
|
+
if (type === 'brand') return item.brand || item.brandNameEn || item.brandNameCn || item.name || 'Untitled brand';
|
|
45
|
+
return item.title || item.titleTh || item.titleEn || item.titleCn || item.name || 'Untitled';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function entityId(type, item, index) {
|
|
49
|
+
const raw = item.id || item.entityId || item.roleCn || item.role || item.brand || item.title || item.titleTh || `${type}-${index + 1}`;
|
|
50
|
+
return String(raw).trim().slice(0, 200);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeRecords(type, result) {
|
|
54
|
+
let records = [];
|
|
55
|
+
if (type === 'work') records = result?.works || [];
|
|
56
|
+
if (type === 'song') records = result?.songs || [];
|
|
57
|
+
if (type === 'role') records = result?.roles || [];
|
|
58
|
+
if (type === 'brand') records = result?.brands || [];
|
|
59
|
+
if (type === 'award') records = result?.awards || [];
|
|
60
|
+
if (type === 'photo') records = result?.photos || [];
|
|
61
|
+
if (type === 'stage') records = result?.videos || [];
|
|
62
|
+
return records.map((item, index) => {
|
|
63
|
+
const title = entityTitle(type, item);
|
|
64
|
+
const detail = type === 'work'
|
|
65
|
+
? [item.role && `Role: ${item.role}`, item.platform, item.episodes && (typeof item.episodes === 'number' || /^\d+$/.test(String(item.episodes)) ? `${item.episodes} episodes` : item.episodes)].filter(Boolean).join(' • ')
|
|
66
|
+
: type === 'song'
|
|
67
|
+
? [item.type, item.duration].filter(Boolean).join(' • ')
|
|
68
|
+
: type === 'role'
|
|
69
|
+
? [item.workTitle, item.roleCn].filter(Boolean).join(' • ')
|
|
70
|
+
: type === 'brand'
|
|
71
|
+
? [item.title, item.category].filter(Boolean).join(' • ')
|
|
72
|
+
: type === 'award'
|
|
73
|
+
? [item.ceremony, item.category].filter(Boolean).join(' • ')
|
|
74
|
+
: type === 'photo'
|
|
75
|
+
? item.category || ''
|
|
76
|
+
: [item.category, item.drama, item.duration].filter(Boolean).join(' • ');
|
|
77
|
+
return {
|
|
78
|
+
type,
|
|
79
|
+
entityId: entityId(type, item, index),
|
|
80
|
+
title: String(title),
|
|
81
|
+
year: Number(item.year) || null,
|
|
82
|
+
detail,
|
|
83
|
+
url: item.youtubeUrl || item.streamUrl || item.youtubeTrailerSearch || item.youtubeSearchUrl || null,
|
|
84
|
+
raw: item,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toolRequest(type, query, options) {
|
|
90
|
+
if (type === 'work') return ['search_works', { query, year: options.year }];
|
|
91
|
+
if (type === 'song') return ['search_discography', { query, year: options.year, type: options.mood === 'calm' ? 'ost' : 'all' }];
|
|
92
|
+
if (type === 'role') return ['get_character_roles', { query }];
|
|
93
|
+
if (type === 'brand') return ['get_brand_endorsements', { query, year: options.year }];
|
|
94
|
+
if (type === 'award') return ['get_awards', { query, year: options.year, limit: options.limit }];
|
|
95
|
+
if (type === 'photo') return ['get_gallery_photos', { query, year: options.year, limit: options.limit }];
|
|
96
|
+
return ['get_music_videos', { query, category: options.mood === 'energetic' ? 'Stage Performance' : undefined }];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function findCatalogEntities(query, options = {}) {
|
|
100
|
+
const type = normalizeType(options.type, 'all');
|
|
101
|
+
const limit = boundedInteger(options.limit, 8, 1, 20);
|
|
102
|
+
const year = options.year === undefined ? undefined : boundedInteger(options.year, undefined, 2013, 2035);
|
|
103
|
+
const executor = options.executeTool || executeTool;
|
|
104
|
+
const types = type === 'all' ? FAVORITE_TYPES : [type];
|
|
105
|
+
const settled = await Promise.allSettled(types.map(async currentType => {
|
|
106
|
+
const [name, args] = toolRequest(currentType, String(query || '').trim(), { ...options, year, limit });
|
|
107
|
+
const result = await executor(name, args);
|
|
108
|
+
return normalizeRecords(currentType, result);
|
|
109
|
+
}));
|
|
110
|
+
const entities = settled.flatMap(entry => entry.status === 'fulfilled' ? entry.value : []);
|
|
111
|
+
const filtered = entities.filter(entity => {
|
|
112
|
+
if (year && entity.year && entity.year !== year) return false;
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
return filtered.slice(0, limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function entityLine(entity, index) {
|
|
119
|
+
const year = entity.year ? ` ${C.dim}(${entity.year})${C.reset}` : '';
|
|
120
|
+
const detail = entity.detail ? ` — ${clean(entity.detail, 180)}` : '';
|
|
121
|
+
return `${C.gold}[${index + 1}]${C.reset} ${C.bold}${C.cyberCyan}${clean(entity.title)}${C.reset}${year} ${C.slate}[${entity.type}]${C.reset}${detail}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handleFind(ctx) {
|
|
125
|
+
const parsed = parsedRequest(ctx);
|
|
126
|
+
const type = normalizeType(parsed.flags.type, 'all');
|
|
127
|
+
const year = parsed.flags.year === undefined ? undefined : boundedInteger(parsed.flags.year, undefined, 2013, 2035);
|
|
128
|
+
const limit = boundedInteger(parsed.flags.limit, 8, 1, 20);
|
|
129
|
+
const query = parsed.positionals.join(' ').trim();
|
|
130
|
+
if (!query && !year && type === 'all') {
|
|
131
|
+
printMessage('Find Command', 'Usage: /find <query> [--type work|song|role|brand|award|photo|stage] [--year YYYY] [--limit N]');
|
|
132
|
+
return 'HANDLED';
|
|
133
|
+
}
|
|
134
|
+
const entities = await findCatalogEntities(query, { type, year, limit, executeTool: ctx.executeTool });
|
|
135
|
+
const summary = entities.length ? await aiNarration(ctx, {
|
|
136
|
+
system: `You are the Kiku catalog guide. In ONE short sentence, tell the user why the top search result matches their query and what to notice about it. ${languageHint(ctx)} Mention only facts present in the data. Never invent facts, dates, or titles. No markdown.`,
|
|
137
|
+
user: JSON.stringify({ query, type, year, results: entities.slice(0, 3).map(e => ({ title: e.title, type: e.type, year: e.year, detail: e.detail })) }),
|
|
138
|
+
limit: 260,
|
|
139
|
+
}) : null;
|
|
140
|
+
printSolidPanel({
|
|
141
|
+
title: `Catalog Search • ${entities.length} result${entities.length === 1 ? '' : 's'}`,
|
|
142
|
+
lines: entities.length
|
|
143
|
+
? [...entities.map(entityLine), ...(summary ? ['', ` ${C.slate}${summary}${C.reset}`] : [])]
|
|
144
|
+
: [`${C.dim}No matching catalog entries. Try another spelling, type, or year.${C.reset}`],
|
|
145
|
+
});
|
|
146
|
+
return 'HANDLED';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function languageHint(ctx) {
|
|
150
|
+
const lang = ctx?.agent?.memory?.preferences?.language || null;
|
|
151
|
+
return lang === 'th' ? 'Answer in Thai.' : lang === 'zh' ? 'Answer in Chinese.' : lang === 'en' ? 'Answer in English.' : 'Answer in the same language as the surrounding UI (English is fine).';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function aiNarration(ctx, { system, user, limit = 400, multiline = false }) {
|
|
155
|
+
const client = ctx?.agent?.client;
|
|
156
|
+
if (!client || typeof client.chat !== 'function') return null;
|
|
157
|
+
try {
|
|
158
|
+
const res = await client.chat({
|
|
159
|
+
messages: [
|
|
160
|
+
{ role: 'system', content: system },
|
|
161
|
+
{ role: 'user', content: user },
|
|
162
|
+
],
|
|
163
|
+
temperature: 0.5,
|
|
164
|
+
stream: false,
|
|
165
|
+
});
|
|
166
|
+
const raw = String(res?.content || '').trim();
|
|
167
|
+
if (!raw) return null;
|
|
168
|
+
if (multiline) {
|
|
169
|
+
return raw.split('\n').map(l => clean(l, limit)).filter(Boolean).join('\n');
|
|
170
|
+
}
|
|
171
|
+
return clean(raw, limit);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function aiDiscoverRationale(ctx, entity, { type, year, mood }) {
|
|
178
|
+
const item = { type, title: entity.title, year: entity.year || year || 'unknown', detail: entity.detail || 'no extra detail' };
|
|
179
|
+
const text = await aiNarration(ctx, {
|
|
180
|
+
system: `You are the Kiku fan-curator. Write ONE short, warm, factual sentence explaining why this Ju Jingyi catalog item is a good pick for the requested vibe. ${languageHint(ctx)} Mention only details present in the item. Never invent facts, dates, or titles. Max ~160 characters. Output only the sentence, no quotes, no markdown.`,
|
|
181
|
+
user: JSON.stringify({ mood, year, type, item }),
|
|
182
|
+
limit: 220,
|
|
183
|
+
});
|
|
184
|
+
return text ? ` ${C.slate}${text}${C.reset}` : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function comparableFields(entity) {
|
|
188
|
+
const raw = entity.raw || {};
|
|
189
|
+
return {
|
|
190
|
+
Type: entity.type,
|
|
191
|
+
Title: entity.title,
|
|
192
|
+
Year: entity.year || 'N/A',
|
|
193
|
+
Role: raw.role || raw.roleCn || 'N/A',
|
|
194
|
+
Category: raw.category || raw.type || 'N/A',
|
|
195
|
+
Platform: raw.platform || 'N/A',
|
|
196
|
+
Episodes: raw.episodes || 'N/A',
|
|
197
|
+
Duration: raw.duration || 'N/A',
|
|
198
|
+
Detail: entity.detail || 'N/A',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function comparisonSection(label, entity) {
|
|
203
|
+
const fields = comparableFields(entity);
|
|
204
|
+
if (entity.type === 'year') {
|
|
205
|
+
return [
|
|
206
|
+
`${C.bold}${C.cyberCyan}${label} ${clean(entity.title)}${C.reset}`,
|
|
207
|
+
` ${C.gold}Works:${C.reset} ${entity.raw.works || 0} ${C.gold}Songs:${C.reset} ${entity.raw.songs || 0} ${C.gold}Brands:${C.reset} ${entity.raw.brands || 0} ${C.gold}Awards:${C.reset} ${entity.raw.awards || 0}`,
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
const lines = [
|
|
211
|
+
`${C.bold}${C.cyberCyan}${label} ${clean(entity.title)}${C.reset}`,
|
|
212
|
+
` ${C.gold}Type:${C.reset} ${clean(fields.Type)} ${C.gold}Year:${C.reset} ${clean(fields.Year)}`,
|
|
213
|
+
];
|
|
214
|
+
for (const field of ['Role', 'Category', 'Platform', 'Episodes', 'Duration']) {
|
|
215
|
+
if (fields[field] !== 'N/A') lines.push(` ${C.gold}${field}:${C.reset} ${clean(fields[field], 220)}`);
|
|
216
|
+
}
|
|
217
|
+
return lines;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function comparisonDifferences(left, right) {
|
|
221
|
+
if (left.type === 'year' && right.type === 'year') {
|
|
222
|
+
return ['works', 'songs', 'brands', 'awards'].map(field => `${C.gold}${field[0].toUpperCase()}${field.slice(1)}:${C.reset} ${left.raw[field] || 0} → ${right.raw[field] || 0}`);
|
|
223
|
+
}
|
|
224
|
+
const leftFields = comparableFields(left);
|
|
225
|
+
const rightFields = comparableFields(right);
|
|
226
|
+
return ['Year', 'Role', 'Category', 'Platform', 'Episodes', 'Duration']
|
|
227
|
+
.filter(field => leftFields[field] !== rightFields[field] && (leftFields[field] !== 'N/A' || rightFields[field] !== 'N/A'))
|
|
228
|
+
.map(field => `${C.gold}${field}:${C.reset} ${clean(leftFields[field], 100)} → ${clean(rightFields[field], 100)}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function yearSummary(year, executor) {
|
|
232
|
+
const types = ['work', 'song', 'brand', 'award'];
|
|
233
|
+
const values = await Promise.all(types.map(type => findCatalogEntities('', { type, year, limit: 20, executeTool: executor })));
|
|
234
|
+
return {
|
|
235
|
+
type: 'year',
|
|
236
|
+
entityId: String(year),
|
|
237
|
+
title: String(year),
|
|
238
|
+
year,
|
|
239
|
+
detail: values.map((items, index) => `${types[index]}: ${items.length}`).join(' • '),
|
|
240
|
+
raw: Object.fromEntries(values.map((items, index) => [`${types[index]}s`, items.length])),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function handleCompare(ctx) {
|
|
245
|
+
const parsed = parsedRequest(ctx);
|
|
246
|
+
const leftQuery = parsed.segments[0] || '';
|
|
247
|
+
const rightQuery = parsed.segments[1] || '';
|
|
248
|
+
if (!leftQuery || !rightQuery) {
|
|
249
|
+
printMessage('Compare Command', 'Usage: /compare <item-a> -- <item-b> [--type work|song|role|brand|award|photo|stage]');
|
|
250
|
+
return 'HANDLED';
|
|
251
|
+
}
|
|
252
|
+
const type = normalizeType(parsed.flags.type, 'all');
|
|
253
|
+
const leftYear = /^20\d{2}$/.test(leftQuery) ? Number(leftQuery) : null;
|
|
254
|
+
const rightYear = /^20\d{2}$/.test(rightQuery) ? Number(rightQuery) : null;
|
|
255
|
+
const [left, right] = leftYear && rightYear
|
|
256
|
+
? await Promise.all([yearSummary(leftYear, ctx.executeTool), yearSummary(rightYear, ctx.executeTool)])
|
|
257
|
+
: await Promise.all([
|
|
258
|
+
findCatalogEntities(leftQuery, { type, limit: 1, executeTool: ctx.executeTool }).then(items => items[0]),
|
|
259
|
+
findCatalogEntities(rightQuery, { type, limit: 1, executeTool: ctx.executeTool }).then(items => items[0]),
|
|
260
|
+
]);
|
|
261
|
+
if (!left || !right) {
|
|
262
|
+
printSolidPanel({ title: 'Compare', lines: [`${C.amber}Could not resolve both items. Use /find first, then compare the exact titles.${C.reset}`] });
|
|
263
|
+
return 'HANDLED';
|
|
264
|
+
}
|
|
265
|
+
const differences = comparisonDifferences(left, right);
|
|
266
|
+
const a = { title: left.title, type: left.type, year: left.year, fields: comparableFields(left) };
|
|
267
|
+
const b = { title: right.title, type: right.type, year: right.year, fields: comparableFields(right) };
|
|
268
|
+
const aiSummary = await aiNarration(ctx, {
|
|
269
|
+
system: `You are the Kiku comparisons editor. In ONE short paragraph, summarize how these two Ju Jingyi catalog items differ and what is notable about each. ${languageHint(ctx)} Mention only facts present in the data. Never invent facts, dates, or titles. Do not use markdown.`,
|
|
270
|
+
user: JSON.stringify({ a, b }),
|
|
271
|
+
limit: 420,
|
|
272
|
+
});
|
|
273
|
+
printSolidPanel({
|
|
274
|
+
title: 'Compare Catalog Entries',
|
|
275
|
+
lines: [
|
|
276
|
+
...comparisonSection('A', left),
|
|
277
|
+
'',
|
|
278
|
+
...comparisonSection('B', right),
|
|
279
|
+
'',
|
|
280
|
+
`${C.bold}${C.roseGlow}Differences${C.reset}`,
|
|
281
|
+
...(differences.length ? differences : [`${C.dim}No differing comparable fields.${C.reset}`]),
|
|
282
|
+
...(aiSummary ? ['', ` ${C.slate}${aiSummary}${C.reset}`] : []),
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
return 'HANDLED';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function handleFavorites(ctx) {
|
|
289
|
+
const parsed = parsedRequest(ctx);
|
|
290
|
+
const action = String(parsed.positionals[0] || 'list').toLowerCase();
|
|
291
|
+
const memory = ctx.agent?.memory;
|
|
292
|
+
if (!memory) throw new Error('Favorites require agent memory.');
|
|
293
|
+
if (action === 'list') {
|
|
294
|
+
const type = normalizeType(parsed.flags.type, 'all');
|
|
295
|
+
const favorites = memory.getFavorites(type);
|
|
296
|
+
printSolidPanel({
|
|
297
|
+
title: `Favorites • ${favorites.length}`,
|
|
298
|
+
lines: favorites.length
|
|
299
|
+
? favorites.map((item, index) => `${C.gold}[${index + 1}]${C.reset} ${C.bold}${C.cyberCyan}${clean(item.title)}${C.reset} ${item.year ? `${C.dim}(${item.year})${C.reset}` : ''} ${C.slate}[${item.type}] ${item.id}${C.reset}`)
|
|
300
|
+
: [`${C.dim}No favorites saved. Use /favorites add <query>.${C.reset}`],
|
|
301
|
+
});
|
|
302
|
+
return 'HANDLED';
|
|
303
|
+
}
|
|
304
|
+
if (action === 'add') {
|
|
305
|
+
let values = parsed.positionals.slice(1);
|
|
306
|
+
const explicit = normalizeType(parsed.flags.type, 'all');
|
|
307
|
+
const rawPositionalType = String(values[0] || '').toLowerCase();
|
|
308
|
+
const hasPositionalType = FAVORITE_TYPES.includes(rawPositionalType) || Boolean(TYPE_ALIASES[rawPositionalType]);
|
|
309
|
+
const positionalType = hasPositionalType ? normalizeType(rawPositionalType, 'all') : 'all';
|
|
310
|
+
const type = explicit !== 'all' ? explicit : hasPositionalType ? positionalType : 'all';
|
|
311
|
+
if (hasPositionalType) values = values.slice(1);
|
|
312
|
+
const query = values.join(' ').trim();
|
|
313
|
+
if (!query) {
|
|
314
|
+
printMessage('Favorites Command', 'Usage: /favorites add [type] <query>');
|
|
315
|
+
return 'HANDLED';
|
|
316
|
+
}
|
|
317
|
+
const [entity] = await findCatalogEntities(query, { type, limit: 1, executeTool: ctx.executeTool });
|
|
318
|
+
if (!entity) {
|
|
319
|
+
printSolidPanel({ title: 'Favorites', lines: [`${C.amber}No matching catalog entry found for "${clean(query)}".${C.reset}`] });
|
|
320
|
+
return 'HANDLED';
|
|
321
|
+
}
|
|
322
|
+
const saved = memory.addFavorite(entity);
|
|
323
|
+
printSolidPanel({
|
|
324
|
+
title: saved.created ? 'Favorite Added' : 'Already in Favorites',
|
|
325
|
+
lines: [entityLine(saved.favorite, 0), `${C.dim}ID: ${saved.favorite.id}${C.reset}`],
|
|
326
|
+
});
|
|
327
|
+
return 'HANDLED';
|
|
328
|
+
}
|
|
329
|
+
if (action === 'remove' || action === 'delete' || action === 'del') {
|
|
330
|
+
const id = parsed.positionals[1] || '';
|
|
331
|
+
const removed = memory.removeFavorite(id);
|
|
332
|
+
console.log(`\n ${removed ? C.emerald + '[OK] Favorite removed.' : C.amber + 'Favorite ID not found.'}${C.reset}\n`);
|
|
333
|
+
return 'HANDLED';
|
|
334
|
+
}
|
|
335
|
+
if (action === 'export') {
|
|
336
|
+
const format = allowlistedValue(parsed.positionals[1], ['json', 'markdown'], 'json');
|
|
337
|
+
const exported = await memory.exportFavorites(format);
|
|
338
|
+
printSolidPanel({ title: 'Favorites Exported', lines: [`${C.emerald}[OK] ${exported.count} items${C.reset}`, `${C.white}${clean(exported.filePath, 500)}${C.reset}`] });
|
|
339
|
+
return 'HANDLED';
|
|
340
|
+
}
|
|
341
|
+
if (action === 'suggest' || action === 'recommend') {
|
|
342
|
+
const favorites = memory.getFavorites();
|
|
343
|
+
const profile = favorites.map(f => ({ type: f.type, title: f.title, year: f.year, detail: f.detail }));
|
|
344
|
+
if (!profile.length) {
|
|
345
|
+
printSolidPanel({ title: 'Favorites Suggest', lines: [`${C.dim}No favorites yet — add some with /favorites add <query>, then ask me to suggest.${C.reset}`] });
|
|
346
|
+
return 'HANDLED';
|
|
347
|
+
}
|
|
348
|
+
const types = ['work', 'song', 'role', 'stage'];
|
|
349
|
+
const poolResults = await Promise.allSettled(types.map(type => findCatalogEntities('', { type, limit: 8, executeTool: ctx.executeTool })));
|
|
350
|
+
const pool = poolResults.flatMap(entry => entry.status === 'fulfilled' ? entry.value.map(e => ({ type: e.type, title: e.title, year: e.year, detail: e.detail })) : []);
|
|
351
|
+
const aiText = await aiNarration(ctx, {
|
|
352
|
+
system: `You are the Kiku personal curator. Based on the fan's saved favorites, recommend 3 catalog items from the available pool that best match their taste. ${languageHint(ctx)} Reply with a plain list, one per line, format exactly: "<title> — <short reason>". Mention only items present in the pool and only facts present in their detail. Never invent titles or facts. No markdown, no numbering prefix.`,
|
|
353
|
+
user: JSON.stringify({ profile, pool }),
|
|
354
|
+
limit: 220,
|
|
355
|
+
multiline: true,
|
|
356
|
+
});
|
|
357
|
+
const suggested = (aiText ? aiText.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 3) : []);
|
|
358
|
+
printSolidPanel({
|
|
359
|
+
title: `Favorites Suggest • based on ${profile.length} favorite${profile.length === 1 ? '' : 's'}`,
|
|
360
|
+
lines: suggested.length
|
|
361
|
+
? suggested.map((line, index) => {
|
|
362
|
+
const separatorIdx = line.indexOf(' — ') >= 0 ? line.indexOf(' — ') : line.indexOf(' - ');
|
|
363
|
+
if (separatorIdx >= 0) {
|
|
364
|
+
const title = clean(line.slice(0, separatorIdx), 80);
|
|
365
|
+
const reason = clean(line.slice(separatorIdx + 3), 220);
|
|
366
|
+
return `${C.gold}[${index + 1}]${C.reset} ${C.bold}${C.cyberCyan}${title}${C.reset} — ${C.slate}${reason}${C.reset}`;
|
|
367
|
+
}
|
|
368
|
+
return `${C.gold}[${index + 1}]${C.reset} ${C.white}${clean(line, 220)}${C.reset}`;
|
|
369
|
+
})
|
|
370
|
+
: [`${C.dim}No suggestions could be generated.${C.reset}`],
|
|
371
|
+
});
|
|
372
|
+
return 'HANDLED';
|
|
373
|
+
}
|
|
374
|
+
printMessage('Favorites Command', 'Usage: /favorites [list|add|remove|export|suggest]');
|
|
375
|
+
return 'HANDLED';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function safeNewsUrl(value) {
|
|
379
|
+
try {
|
|
380
|
+
const url = new URL(value);
|
|
381
|
+
if (!['http:', 'https:'].includes(url.protocol)) return null;
|
|
382
|
+
return url;
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function detectedDate(item) {
|
|
389
|
+
const text = `${item.title || ''} ${item.snippet || ''}`;
|
|
390
|
+
const match = text.match(/\b(20\d{2})[-/]([01]?\d)[-/]([0-3]?\d)\b/);
|
|
391
|
+
if (!match) return null;
|
|
392
|
+
const date = new Date(`${match[1]}-${String(match[2]).padStart(2, '0')}-${String(match[3]).padStart(2, '0')}T00:00:00Z`);
|
|
393
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function handleNews(ctx) {
|
|
397
|
+
const parsed = parsedRequest(ctx);
|
|
398
|
+
const days = boundedInteger(parsed.flags.days, 7, 1, 90);
|
|
399
|
+
const officialOnly = parsed.flags['official-only'] === true || String(parsed.flags['official-only']).toLowerCase() === 'true';
|
|
400
|
+
const topic = parsed.positionals.join(' ').trim();
|
|
401
|
+
const now = new Date();
|
|
402
|
+
const cutoff = new Date(now.getTime() - days * 86_400_000);
|
|
403
|
+
const after = cutoff.toISOString().slice(0, 10);
|
|
404
|
+
const before = new Date(now.getTime() + 86_400_000).toISOString().slice(0, 10);
|
|
405
|
+
const query = `Ju Jingyi 鞠婧祎 ${topic || 'latest news'} after:${after} before:${before}${officialOnly ? ' official studio' : ''}`;
|
|
406
|
+
const executor = ctx.executeTool || executeTool;
|
|
407
|
+
const result = await executor('web_search', { query });
|
|
408
|
+
const seen = new Set();
|
|
409
|
+
const items = (result?.results || []).filter(item => {
|
|
410
|
+
const url = safeNewsUrl(item.url);
|
|
411
|
+
if (!url || seen.has(url.href)) return false;
|
|
412
|
+
if (officialOnly && (!OFFICIAL_NEWS_HOSTS.has(url.hostname.toLowerCase()) || !OFFICIAL_NEWS_SIGNAL.test(`${item.title || ''} ${item.snippet || ''} ${url.pathname}`))) return false;
|
|
413
|
+
const date = detectedDate(item);
|
|
414
|
+
if (date && date.getTime() < cutoff.getTime()) return false;
|
|
415
|
+
seen.add(url.href);
|
|
416
|
+
return true;
|
|
417
|
+
}).slice(0, 8);
|
|
418
|
+
const summary = items.length ? await aiNarration(ctx, {
|
|
419
|
+
system: `You are the Kiku news desk. In ONE short paragraph (2-3 sentences), summarize what these Ju Jingyi news results say this week. ${languageHint(ctx)} Synthesize only from the provided items. Never invent facts, dates, or sources. No markdown.`,
|
|
420
|
+
user: JSON.stringify({ topic, items: items.slice(0, 8).map((item, index) => ({ title: item.title, snippet: item.snippet, source: safeNewsUrl(item.url)?.hostname || item.source })) }),
|
|
421
|
+
limit: 420,
|
|
422
|
+
}) : null;
|
|
423
|
+
printSolidPanel({
|
|
424
|
+
title: `${officialOnly ? 'Official-source ' : ''}News Search • ${items.length} result${items.length === 1 ? '' : 's'} • requested ${days}-day window`,
|
|
425
|
+
lines: items.length
|
|
426
|
+
? [...items.flatMap((item, index) => {
|
|
427
|
+
const url = safeNewsUrl(item.url);
|
|
428
|
+
const date = detectedDate(item);
|
|
429
|
+
return [
|
|
430
|
+
`${C.gold}[${index + 1}]${C.reset} ${C.bold}${C.cyberCyan}${clean(item.title)}${C.reset}`,
|
|
431
|
+
` ${C.dim}${date ? date.toISOString().slice(0, 10) : 'date unavailable • recency unverified'} • ${clean(url?.hostname || item.source || 'source', 100)}${C.reset}`,
|
|
432
|
+
` ${C.white}${clean(item.snippet, 260)}${C.reset}`,
|
|
433
|
+
` ${C.slate}${clean(url?.href || '', 500)}${C.reset}`,
|
|
434
|
+
];
|
|
435
|
+
}), ...(summary ? ['', ` ${C.slate}${summary}${C.reset}`] : [])]
|
|
436
|
+
: [`${C.dim}No matching ${officialOnly ? 'official ' : ''}news found. Try a wider date range or another topic.${C.reset}`],
|
|
437
|
+
});
|
|
438
|
+
return 'HANDLED';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function jobLine(job, index) {
|
|
442
|
+
const color = job.state === 'completed' ? C.emerald : job.state === 'failed' ? C.red : job.state === 'cancelled' ? C.amber : C.cyberCyan;
|
|
443
|
+
return `${C.gold}[${index + 1}]${C.reset} ${color}${job.state.toUpperCase()}${C.reset} ${C.bold}${clean(job.type, 30)}${C.reset} ${clean(job.label, 120)} ${C.dim}${job.id}${C.reset}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function handleJobs(ctx) {
|
|
447
|
+
const parsed = parsedRequest(ctx);
|
|
448
|
+
const action = String(parsed.positionals[0] || 'list').toLowerCase();
|
|
449
|
+
if (action === 'list') {
|
|
450
|
+
const jobs = typeof ctx.agent?.getJobs === 'function' ? ctx.agent.getJobs() : [];
|
|
451
|
+
printSolidPanel({ title: `Agent Jobs • ${jobs.length}`, lines: jobs.length ? jobs.map(jobLine) : [`${C.dim}No tracked jobs yet.${C.reset}`] });
|
|
452
|
+
return 'HANDLED';
|
|
453
|
+
}
|
|
454
|
+
if (action === 'show') {
|
|
455
|
+
const id = parsed.positionals[1] || '';
|
|
456
|
+
const job = typeof ctx.agent?.getJob === 'function' ? ctx.agent.getJob(id) : null;
|
|
457
|
+
printSolidPanel({
|
|
458
|
+
title: job ? `Job • ${job.id}` : 'Job Not Found',
|
|
459
|
+
lines: job ? [jobLine(job, 0), `${C.gold}Started:${C.reset} ${clean(job.startedAt)}`, `${C.gold}Completed:${C.reset} ${clean(job.completedAt || 'N/A')}`, ...(job.error ? [`${C.red}Error: ${clean(job.error)}${C.reset}`] : [])] : [`${C.amber}No job found for that ID.${C.reset}`],
|
|
460
|
+
});
|
|
461
|
+
return 'HANDLED';
|
|
462
|
+
}
|
|
463
|
+
if (action === 'cancel') {
|
|
464
|
+
const id = parsed.positionals[1] || '';
|
|
465
|
+
const cancelled = /^job_[a-zA-Z0-9_]+$/.test(id) && typeof ctx.agent?.cancelJob === 'function' && ctx.agent.cancelJob(id);
|
|
466
|
+
console.log(`\n ${cancelled ? C.emerald + '[OK] Job cancellation requested.' : C.amber + 'Running job not found.'}${C.reset}\n`);
|
|
467
|
+
return 'HANDLED';
|
|
468
|
+
}
|
|
469
|
+
if (action === 'clear') {
|
|
470
|
+
const count = typeof ctx.agent?.clearJobs === 'function' ? ctx.agent.clearJobs() : 0;
|
|
471
|
+
console.log(`\n ${C.emerald}[OK] Cleared ${count} completed job${count === 1 ? '' : 's'}.${C.reset}\n`);
|
|
472
|
+
return 'HANDLED';
|
|
473
|
+
}
|
|
474
|
+
printMessage('Jobs Command', 'Usage: /jobs [list|show <id>|cancel <id>|clear]');
|
|
475
|
+
return 'HANDLED';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function handleDiscover(ctx) {
|
|
479
|
+
const parsed = parsedRequest(ctx);
|
|
480
|
+
const requested = parsed.positionals[0] || parsed.flags.type || 'drama';
|
|
481
|
+
const type = normalizeType(requested, 'work');
|
|
482
|
+
if (type === 'all' || ['role', 'brand', 'award'].includes(type)) throw new Error('Discover type must be drama, song, stage, or photo.');
|
|
483
|
+
const year = parsed.flags.year === undefined ? undefined : boundedInteger(parsed.flags.year, undefined, 2013, 2035);
|
|
484
|
+
const mood = allowlistedValue(parsed.flags.mood, ['calm', 'romantic', 'energetic', 'dramatic', 'any'], 'any');
|
|
485
|
+
let entities = await findCatalogEntities('', { type, year, mood, limit: 20, executeTool: ctx.executeTool });
|
|
486
|
+
if (!entities.length && mood !== 'any') entities = await findCatalogEntities('', { type, year, limit: 20, executeTool: ctx.executeTool });
|
|
487
|
+
const random = typeof ctx.random === 'function' ? ctx.random : Math.random;
|
|
488
|
+
const entity = entities[Math.floor(random() * entities.length)];
|
|
489
|
+
const rationale = entity ? await aiDiscoverRationale(ctx, entity, { type, year, mood }) : null;
|
|
490
|
+
printSolidPanel({
|
|
491
|
+
title: `Discover • ${type}${year ? ` • ${year}` : ''}${mood !== 'any' ? ` • ${mood}` : ''}`,
|
|
492
|
+
lines: entity
|
|
493
|
+
? [entityLine(entity, 0), ...(rationale ? [rationale] : []), ...(entity.url ? [`${C.slate}${clean(entity.url, 500)}${C.reset}`] : []), `${C.dim}Save it with /favorites add --type ${entity.type} "${clean(entity.title, 100)}"${C.reset}`]
|
|
494
|
+
: [`${C.dim}No matching catalog entry is available for these filters.${C.reset}`],
|
|
495
|
+
});
|
|
496
|
+
return 'HANDLED';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function safeHandler(handler) {
|
|
500
|
+
return async ctx => {
|
|
501
|
+
try {
|
|
502
|
+
return await handler(ctx);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
printMessage('Command Error', `[COMMAND ERROR] ${error?.message || error}`, C.red);
|
|
505
|
+
return 'HANDLED';
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export const featureCommandHandlers = Object.freeze({
|
|
511
|
+
'/find': safeHandler(handleFind),
|
|
512
|
+
'/compare': safeHandler(handleCompare),
|
|
513
|
+
'/favorites': safeHandler(handleFavorites),
|
|
514
|
+
'/news': safeHandler(handleNews),
|
|
515
|
+
'/jobs': safeHandler(handleJobs),
|
|
516
|
+
'/discover': safeHandler(handleDiscover),
|
|
517
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const MAX_COMMAND_LENGTH = 1000;
|
|
2
|
+
const MAX_TOKEN_LENGTH = 500;
|
|
3
|
+
|
|
4
|
+
export function tokenizeCommandInput(input) {
|
|
5
|
+
const value = String(input || '').trim();
|
|
6
|
+
if (!value) return [];
|
|
7
|
+
if (value.length > MAX_COMMAND_LENGTH) throw new Error(`Command must be ${MAX_COMMAND_LENGTH} characters or fewer.`);
|
|
8
|
+
|
|
9
|
+
const tokens = [];
|
|
10
|
+
let current = '';
|
|
11
|
+
let quote = '';
|
|
12
|
+
let escaped = false;
|
|
13
|
+
|
|
14
|
+
for (const char of value) {
|
|
15
|
+
if (escaped) {
|
|
16
|
+
current += char;
|
|
17
|
+
if (current.length > MAX_TOKEN_LENGTH) throw new Error(`Command argument must be ${MAX_TOKEN_LENGTH} characters or fewer.`);
|
|
18
|
+
escaped = false;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (char === '\\' && quote) {
|
|
22
|
+
escaped = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (quote) {
|
|
26
|
+
if (char === quote) {
|
|
27
|
+
quote = '';
|
|
28
|
+
} else {
|
|
29
|
+
current += char;
|
|
30
|
+
if (current.length > MAX_TOKEN_LENGTH) throw new Error(`Command argument must be ${MAX_TOKEN_LENGTH} characters or fewer.`);
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (char === '"' || char === "'") {
|
|
35
|
+
quote = char;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (/\s/u.test(char)) {
|
|
39
|
+
if (current) {
|
|
40
|
+
tokens.push(current);
|
|
41
|
+
current = '';
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
current += char;
|
|
46
|
+
if (current.length > MAX_TOKEN_LENGTH) throw new Error(`Command argument must be ${MAX_TOKEN_LENGTH} characters or fewer.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (escaped) current += '\\';
|
|
50
|
+
if (quote) throw new Error('Command contains an unclosed quote.');
|
|
51
|
+
if (current) tokens.push(current);
|
|
52
|
+
return tokens;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseCommandInput(input) {
|
|
56
|
+
const tokens = tokenizeCommandInput(input);
|
|
57
|
+
const command = String(tokens.shift() || '').toLowerCase();
|
|
58
|
+
const flags = {};
|
|
59
|
+
const segments = [[]];
|
|
60
|
+
|
|
61
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
62
|
+
const token = tokens[index];
|
|
63
|
+
if (token === '--') {
|
|
64
|
+
if (segments.length === 1) segments.push([]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (token.startsWith('--') && token.length > 2) {
|
|
68
|
+
const rawFlag = token.slice(2);
|
|
69
|
+
const equalsIndex = rawFlag.indexOf('=');
|
|
70
|
+
if (equalsIndex !== -1) {
|
|
71
|
+
const key = rawFlag.slice(0, equalsIndex).toLowerCase();
|
|
72
|
+
const value = rawFlag.slice(equalsIndex + 1);
|
|
73
|
+
if (!key) throw new Error('Invalid command flag.');
|
|
74
|
+
flags[key] = value || true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const key = rawFlag.toLowerCase();
|
|
78
|
+
const next = tokens[index + 1];
|
|
79
|
+
if (next && next !== '--' && !next.startsWith('--')) {
|
|
80
|
+
flags[key] = next;
|
|
81
|
+
index += 1;
|
|
82
|
+
} else {
|
|
83
|
+
flags[key] = true;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
segments[segments.length - 1].push(token);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
command,
|
|
92
|
+
tokens,
|
|
93
|
+
flags,
|
|
94
|
+
positionals: segments.flat(),
|
|
95
|
+
segments: segments.map(segment => segment.join(' ').trim()),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function boundedInteger(value, fallback, minimum, maximum) {
|
|
100
|
+
if (value === undefined || value === null || value === true || value === '') return fallback;
|
|
101
|
+
const number = Number(value);
|
|
102
|
+
if (!Number.isInteger(number) || number < minimum || number > maximum) {
|
|
103
|
+
throw new Error(`Expected an integer from ${minimum} to ${maximum}.`);
|
|
104
|
+
}
|
|
105
|
+
return number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function allowlistedValue(value, allowed, fallback) {
|
|
109
|
+
if (value === undefined || value === null || value === true || value === '') return fallback;
|
|
110
|
+
const normalized = String(value).toLowerCase();
|
|
111
|
+
if (!allowed.includes(normalized)) throw new Error(`Expected one of: ${allowed.join(', ')}.`);
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|