kiku-agent 1.2.0 → 1.3.1

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 (131) hide show
  1. package/README.md +459 -407
  2. package/package.json +32 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +637 -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 +532 -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/ink/App.jsx +348 -0
  23. package/server/agent/ink/Panel.jsx +54 -0
  24. package/server/agent/ink/PromptInput.jsx +321 -0
  25. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  26. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  27. package/server/agent/ink/ToolResult.jsx +133 -0
  28. package/server/agent/ink/main.jsx +195 -0
  29. package/server/agent/ink/useEventBus.js +218 -0
  30. package/server/agent/lanDiscovery.js +369 -0
  31. package/server/agent/llmClient.js +111 -39
  32. package/server/agent/lmStudioWakeup.js +144 -0
  33. package/server/agent/mcpClient.js +2 -2
  34. package/server/agent/memory/contextAllocator.js +214 -0
  35. package/server/agent/memory/hierarchicalStore.js +523 -0
  36. package/server/agent/memory/reflectionWorker.js +110 -0
  37. package/server/agent/memory.js +635 -494
  38. package/server/agent/modelConfig.js +487 -0
  39. package/server/agent/newsIntelligence.js +219 -0
  40. package/server/agent/newsWatch.js +85 -0
  41. package/server/agent/prompts.js +146 -0
  42. package/server/agent/publicTools.js +7 -0
  43. package/server/agent/queryDetails.js +62 -1
  44. package/server/agent/research.js +314 -35
  45. package/server/agent/routingInterceptor.js +77 -0
  46. package/server/agent/rumoredProjects.js +15 -0
  47. package/server/agent/sanitizer.js +117 -17
  48. package/server/agent/subagent.js +52 -9
  49. package/server/agent/suggestionTui.js +33 -13
  50. package/server/agent/suggestions.js +29 -13
  51. package/server/agent/telemetry/hardwareTiers.js +332 -0
  52. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  53. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  54. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  55. package/server/agent/telemetry.js +0 -11
  56. package/server/agent/terminalImage.js +0 -1
  57. package/server/agent/textToolProtocol.js +190 -0
  58. package/server/agent/tokenTracker.js +220 -0
  59. package/server/agent/toolRouter.js +362 -216
  60. package/server/agent/tools.js +1155 -194
  61. package/server/agent/ui.js +144 -41
  62. package/server/agent/vramService.js +572 -0
  63. package/server/agent/webSearch.js +2279 -273
  64. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  65. package/server/agent/websearch/adapters/jina.js +77 -0
  66. package/server/agent/websearch/adapters/searxng.js +57 -0
  67. package/server/agent/websearch/core/canonical.js +89 -0
  68. package/server/agent/websearch/core/fusion.js +81 -0
  69. package/server/agent/websearch/core/types.js +29 -0
  70. package/server/agent/websearch/extract.js +605 -0
  71. package/server/agent/websearch/index.js +43 -0
  72. package/server/agent/websearch/orchestrator.js +266 -0
  73. package/server/agent/websearch/resilience/breaker.js +97 -0
  74. package/server/agent/websearch/resilience/cache.js +59 -0
  75. package/server/agent/websearch/resilience/limiter.js +93 -0
  76. package/server/agent/websearch/security/filter.js +36 -0
  77. package/server/agent/websearch/security/ssrf.js +289 -0
  78. package/server/agent/websearch/telemetry.js +71 -0
  79. package/server/agent/youtubeAuth.js +82 -73
  80. package/server/agent/youtubeTranscript.js +209 -0
  81. package/server/apiRouter.js +390 -190
  82. package/server/mcp/data/brands.js +5 -5
  83. package/server/mcp/data/discography.js +56 -45
  84. package/server/mcp/data/gallery.js +9 -0
  85. package/server/mcp/data/index.js +1 -0
  86. package/server/mcp/data/industryIndex.js +251 -0
  87. package/server/mcp/data/profile.js +5 -5
  88. package/server/mcp/data/timeline.js +2 -2
  89. package/server/mcp/data/works.js +2 -2
  90. package/server/mcp/handler.js +0 -2
  91. package/server/mcp/prompts.js +42 -2
  92. package/server/mcp/resources.js +5 -1
  93. package/server/mcp/security.js +25 -14
  94. package/server/mcp/server.js +92 -3
  95. package/server/mcp/tools.js +20 -11
  96. package/server/routes/agent.js +55 -0
  97. package/server/web-chat/core/connection.js +218 -194
  98. package/server/web-chat/core/conversationContext.js +1 -1
  99. package/server/web-chat/core/directCatalogEngine.js +190 -184
  100. package/server/web-chat/core/followUpEngine.js +88 -11
  101. package/server/web-chat/core/security.js +7 -4
  102. package/server/web-chat/router.js +54 -17
  103. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  104. package/server/web-chat/routes/chatRoute.js +488 -397
  105. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  106. package/server/web-chat/routes/exportRoute.js +2 -1
  107. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  108. package/server/web-chat/routes/newsRoute.js +36 -6
  109. package/server/web-chat/routes/providerRoute.js +87 -0
  110. package/server/web-chat/routes/staticRoute.js +61 -13
  111. package/server/web-chat/routes/statusRoute.js +23 -2
  112. package/server/web-chat/routes/vramRoute.js +22 -0
  113. package/server/web-chat/server.js +135 -106
  114. package/server/web-chat/ui/htmlBuilder.js +8 -8
  115. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  116. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  117. package/server/web-chat/ui/scripts/client.js +10526 -8397
  118. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  119. package/server/web-chat/ui/styles/main.css +7471 -4246
  120. package/server/web-chat/ui/templates/body.html +305 -179
  121. package/src/data/brandsData.js +29 -6
  122. package/src/data/discographyData.js +918 -900
  123. package/src/data/galleryData.js +66 -0
  124. package/src/data/industryIndexData.js +292 -0
  125. package/src/data/musicVideoData.js +48 -5
  126. package/src/data/profileData.js +15 -1
  127. package/src/data/timelineData.js +4 -3
  128. package/src/data/worksData.js +536 -535
  129. package/src/lib/vram/estimator.js +464 -0
  130. package/src/lib/vram/modelRegistry.js +387 -0
  131. package/tokens.css +33 -2
@@ -2,19 +2,219 @@ import { ResearchSubagent } from './subagent.js';
2
2
  import { executeTool } from './tools.js';
3
3
  import { eventBus, Events } from './eventBus.js';
4
4
  import { mapConcurrent } from './suggestions.js';
5
+ import { checkUrlLiveness } from './webSearch.js';
5
6
 
6
- const CATALOG_TOOLS = Object.freeze([
7
+ export const CATALOG_TOOLS = Object.freeze([
7
8
  'global_search', 'get_profile_and_fandom', 'search_works', 'get_work_detail',
8
9
  'get_character_roles', 'search_discography', 'get_music_videos',
9
10
  'get_media_links', 'get_brand_endorsements', 'get_career_timeline',
10
11
  'get_awards', 'get_social_feed',
11
12
  ]);
12
- const WEB_TOOLS = Object.freeze(['web_search', 'web_extract', 'fetch_webpage']);
13
- const VERIFY_TOOLS = Object.freeze([...CATALOG_TOOLS, ...WEB_TOOLS]);
13
+ export const WEB_TOOLS = Object.freeze([
14
+ 'web_search', 'web_extract', 'fetch_webpage',
15
+ 'search_chinese_web', 'search_web_images', 'search_news', 'search_encyclopedia'
16
+ ]);
17
+ export const VERIFY_TOOLS = Object.freeze([...CATALOG_TOOLS, ...WEB_TOOLS]);
18
+
19
+ export function cleanUrl(rawUrl = '') {
20
+ let url = String(rawUrl || '').trim();
21
+ const hasTrailingEllipsis = /\.{3,}$|…$/.test(url);
22
+ let prev;
23
+ do {
24
+ prev = url;
25
+ url = url.replace(/^[`"'\(\[<|\s]+/, '');
26
+ url = url.replace(/[`"'\)\]>|\s]+$/, '');
27
+ url = url.replace(/(?:(?<!\.)\.(?!\.)|[;:!?,])+$/, '');
28
+ } while (url !== prev && url.length > 0);
29
+ if (hasTrailingEllipsis && !url.includes('...') && !url.includes('…')) {
30
+ url = `${url.replace(/\/+$/, '')}/...`;
31
+ }
32
+ return url;
33
+ }
34
+
35
+ export function extractAllRealUrls(toolResults = []) {
36
+ const urls = new Set();
37
+ const add = (candidate) => {
38
+ if (!candidate || typeof candidate !== 'string') return;
39
+ const cleaned = cleanUrl(candidate);
40
+ if (/^https?:\/\/[a-zA-Z0-9]/i.test(cleaned) && !cleaned.includes('...') && !cleaned.includes('…')) {
41
+ urls.add(cleaned);
42
+ }
43
+ };
44
+
45
+ const traverse = (obj, depth = 0) => {
46
+ if (!obj || depth > 5) return;
47
+ if (typeof obj === 'string') {
48
+ const found = obj.match(/https?:\/\/[^\s<>()"'|\[\]`]+/g) || [];
49
+ for (const f of found) add(f);
50
+ } else if (Array.isArray(obj)) {
51
+ for (const item of obj) traverse(item, depth + 1);
52
+ } else if (typeof obj === 'object') {
53
+ if (typeof obj.url === 'string') add(obj.url);
54
+ if (typeof obj.link === 'string') add(obj.link);
55
+ for (const key of Object.keys(obj)) {
56
+ traverse(obj[key], depth + 1);
57
+ }
58
+ }
59
+ };
60
+
61
+ for (const item of toolResults) {
62
+ traverse(item);
63
+ }
64
+ return [...urls];
65
+ }
66
+
67
+ export function resolveTruncatedUrl(truncatedUrl, knownUrls = []) {
68
+ const cleaned = cleanUrl(truncatedUrl);
69
+ const normalized = cleaned.replace(/…/g, '...');
70
+ if (!normalized.includes('...')) return normalized;
71
+
72
+ if (!knownUrls || knownUrls.length === 0) {
73
+ return normalized
74
+ .replace(/\/+\.\.\.[^\/]*$/, '')
75
+ .replace(/\/\.\.\.\//g, '/')
76
+ .replace(/\.{3,}/g, '');
77
+ }
78
+
79
+ try {
80
+ let domain = '';
81
+ try {
82
+ const parsed = new URL(normalized.replace(/\.\.\./g, 'placeholder'));
83
+ domain = parsed.hostname.replace(/^www\./, '').toLowerCase();
84
+ } catch {
85
+ const match = normalized.match(/https?:\/\/(?:www\.)?([^\/\s:]+)/i);
86
+ if (match) domain = match[1].toLowerCase();
87
+ }
88
+
89
+ const candidates = knownUrls.filter(known => {
90
+ if (!domain) return true;
91
+ try {
92
+ const kHost = new URL(known).hostname.replace(/^www\./, '').toLowerCase();
93
+ return kHost === domain || kHost.endsWith('.' + domain) || domain.endsWith('.' + kHost);
94
+ } catch {
95
+ return known.toLowerCase().includes(domain);
96
+ }
97
+ });
98
+
99
+ const pool = candidates.length > 0 ? candidates : knownUrls;
100
+
101
+ const rawTokens = decodeURIComponent(normalized)
102
+ .replace(/^https?:\/\/(?:www\.)?/i, '')
103
+ .split(/[\/\?#&]+/)
104
+ .flatMap(part => part.split(/\.\.\./))
105
+ .map(part => part.trim().replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, ''))
106
+ .filter(part => part.length >= 3 && !/^\.+$/.test(part));
107
+
108
+ const idTokens = rawTokens.filter(t => /^\d{4,}$/.test(t));
109
+
110
+ let bestMatch = null;
111
+ let highestScore = 0;
112
+
113
+ for (const known of pool) {
114
+ const decodedKnown = decodeURIComponent(known).toLowerCase();
115
+ let score = 0;
116
+
117
+ for (const id of idTokens) {
118
+ if (decodedKnown.includes(id.toLowerCase())) {
119
+ score += 50;
120
+ }
121
+ }
122
+
123
+ for (const token of rawTokens) {
124
+ const lowerToken = token.toLowerCase();
125
+ if (decodedKnown.includes(lowerToken)) {
126
+ score += 10;
127
+ }
128
+ }
129
+
130
+ if (score > highestScore) {
131
+ highestScore = score;
132
+ bestMatch = known;
133
+ }
134
+ }
135
+
136
+ if (bestMatch && (highestScore >= 20 || (idTokens.length > 0 && highestScore >= 50))) {
137
+ return bestMatch;
138
+ }
139
+ } catch {}
140
+
141
+ return normalized
142
+ .replace(/\/+\.\.\.[^\/]*$/, '')
143
+ .replace(/\/\.\.\.\//g, '/')
144
+ .replace(/\.{3,}/g, '');
145
+ }
146
+
147
+ export function expandTruncatedUrlsInText(text, knownUrls = []) {
148
+ if (!text) return text;
149
+ return text.replace(/https?:\/\/[^\s<>()"'\n`|\]]+(?:\.\.\.|…)[^\s<>()"'\n`|\]]*/g, (match) => {
150
+ let urlCandidate = match;
151
+ let trailingPunct = '';
152
+ const punctMatch = urlCandidate.match(/([,;:!?]+)$/);
153
+ if (punctMatch) {
154
+ trailingPunct = punctMatch[1];
155
+ urlCandidate = urlCandidate.slice(0, -trailingPunct.length);
156
+ }
157
+ const resolved = resolveTruncatedUrl(urlCandidate, knownUrls);
158
+ return resolved + trailingPunct;
159
+ });
160
+ }
161
+
162
+ export function uniqueUrls(text, knownUrls = []) {
163
+ const matches = String(text || '').match(/https?:\/\/[^\s<>()\]`"'|]+/g) || [];
164
+ const resolvedList = matches.map(url => resolveTruncatedUrl(cleanUrl(url), knownUrls));
165
+ const seen = new Set();
166
+ const results = [];
167
+
168
+ for (const u of resolvedList) {
169
+ if (!u || !/^https?:\/\/[a-zA-Z0-9]/i.test(u)) continue;
170
+ if (u.includes('...') || u.includes('…')) continue;
171
+ const cleanU = cleanUrl(u);
172
+ if (!seen.has(cleanU)) {
173
+ seen.add(cleanU);
174
+ results.push(cleanU);
175
+ }
176
+ }
177
+
178
+ for (const known of knownUrls) {
179
+ try {
180
+ const cleanKnown = cleanUrl(known);
181
+ if (cleanKnown.includes('...') || cleanKnown.includes('…')) continue;
182
+ const kObj = new URL(cleanKnown);
183
+ const host = kObj.hostname.replace(/^www\./, '');
184
+ if (host.length > 4 && text.toLowerCase().includes(host.toLowerCase()) && !seen.has(cleanKnown)) {
185
+ seen.add(cleanKnown);
186
+ results.push(cleanKnown);
187
+ }
188
+ } catch {}
189
+ }
190
+
191
+ return results;
192
+ }
193
+
194
+ export async function filterAliveSources(urls = [], realToolUrls = [], options = {}) {
195
+ if (!Array.isArray(urls) || urls.length === 0) return [];
196
+ if (options.checkLiveness === false || process.env.KIKU_SKIP_URL_LIVENESS === 'true') {
197
+ return urls.map(cleanUrl).filter(u => u && !u.includes('...') && !u.includes('…'));
198
+ }
199
+ const realSet = new Set(realToolUrls.map(u => cleanUrl(u).toLowerCase()));
200
+ const cleanedUrls = urls.map(cleanUrl);
201
+
202
+ const verified = [];
203
+ await mapConcurrent(cleanedUrls, 5, async (u) => {
204
+ if (!u || u.includes('...') || u.includes('…')) return;
205
+
206
+ const isDirectFromTool = realSet.has(u.toLowerCase());
14
207
 
15
- function uniqueUrls(text) {
16
- const matches = String(text || '').match(/https?:\/\/[^\s<>()\]]+/g) || [];
17
- return [...new Set(matches.map(url => url.replace(/[.,;:!?]+$/, '')))];
208
+ const check = await checkUrlLiveness(u, { timeoutMs: 2500 });
209
+ if (check.alive) {
210
+ verified.push(u);
211
+ } else if (isDirectFromTool && check.reason !== 'not_found' && check.reason !== 'dns_failed') {
212
+ verified.push(u);
213
+ }
214
+ });
215
+
216
+ const verifiedSet = new Set(verified);
217
+ return cleanedUrls.filter(u => verifiedSet.has(u));
18
218
  }
19
219
 
20
220
  function stableSignature(name, args) {
@@ -63,6 +263,7 @@ export class ResearchOrchestrator {
63
263
  this.maxIterations = Math.min(Math.max(options.maxIterations || 3, 1), 5);
64
264
  this.active = null;
65
265
  this.lastResult = null;
266
+ this._runLock = null;
66
267
  }
67
268
 
68
269
  getStatus() {
@@ -88,15 +289,31 @@ export class ResearchOrchestrator {
88
289
  if (!query) throw new Error('A research question is required.');
89
290
 
90
291
 
292
+ while (this._runLock) {
293
+ try { await this._runLock; } catch { }
294
+ }
295
+ let releaseLock;
296
+ this._runLock = new Promise(resolve => { releaseLock = resolve; });
297
+
298
+ try {
299
+ return await this._run(query, options);
300
+ } finally {
301
+ this._runLock = null;
302
+ releaseLock();
303
+ }
304
+ }
305
+
306
+ async _run(query, options = {}) {
91
307
  if (this.active && this.active.question === query && this.active.promise) {
92
308
  try {
93
309
  return await this.active.promise;
94
310
  } catch (err) {
95
-
311
+
312
+ console.warn('[Kiku research] previous identical run failed, starting a new one:', err?.message || err);
96
313
  }
97
314
  }
98
315
 
99
-
316
+
100
317
  if (this.active) {
101
318
  try {
102
319
  this.active.controller.abort();
@@ -140,6 +357,11 @@ export class ResearchOrchestrator {
140
357
  );
141
358
  const concurrency = isLocalProvider ? 1 : Math.min(this.maxWorkers, plan.length);
142
359
 
360
+ const overallTimeoutMs = Number(options.timeout || process.env.KIKU_RESEARCH_TIMEOUT_MS) || (isLocalProvider ? 240000 : 120000);
361
+ const overallTimer = setTimeout(() => {
362
+ controller.abort(new Error(`Deep research timed out after ${overallTimeoutMs / 1000}s`));
363
+ }, overallTimeoutMs);
364
+
143
365
  const executeJob = async () => {
144
366
  try {
145
367
  const settled = await mapConcurrent(plan, concurrency, async task => {
@@ -153,29 +375,50 @@ export class ResearchOrchestrator {
153
375
  label: task.label,
154
376
  });
155
377
 
156
- const worker = new ResearchSubagent({
157
- client: this.client,
158
- executeTool: cachedExecute,
159
- allowedTools: task.tools,
160
- maxIterations: this.maxIterations,
161
- });
162
- const result = await worker.run(task.objective, {
163
- signal: controller.signal,
164
- skillContext: options.skillContext,
165
- onProgress: event => {
166
- if (event.type === 'tool') status.toolCalls += 1;
167
- options.onProgress?.({ ...event, workerId: task.id, label: task.label });
168
- },
169
- });
170
- status.state = 'complete';
171
- options.onProgress?.({ type: 'worker-complete', id: task.id, label: task.label });
172
- eventBus.emit(Events.AGENT_PLAN_STEP_UPDATE, {
173
- planId: job.id,
174
- stepId: task.id,
175
- state: 'complete',
176
- label: task.label,
177
- });
178
- return { id: task.id, label: task.label, ...result };
378
+ const workerTimeoutMs = Number(options.workerTimeoutMs || process.env.KIKU_RESEARCH_WORKER_TIMEOUT_MS) || (isLocalProvider ? 90000 : 45000);
379
+ const workerAbort = new AbortController();
380
+ const timer = setTimeout(() => {
381
+ workerAbort.abort(new Error(`Worker ${task.label} timed out after ${workerTimeoutMs / 1000}s`));
382
+ }, workerTimeoutMs);
383
+ const onParentAbort = () => workerAbort.abort(controller.signal.reason);
384
+ controller.signal.addEventListener('abort', onParentAbort, { once: true });
385
+
386
+ try {
387
+ const worker = new ResearchSubagent({
388
+ client: this.client,
389
+ executeTool: cachedExecute,
390
+ allowedTools: task.tools,
391
+ maxIterations: this.maxIterations,
392
+ });
393
+ const result = await worker.run(task.objective, {
394
+ signal: workerAbort.signal,
395
+ skillContext: options.skillContext,
396
+ onProgress: event => {
397
+ if (event.type === 'tool') status.toolCalls += 1;
398
+ options.onProgress?.({ ...event, workerId: task.id, label: task.label });
399
+ },
400
+ });
401
+ clearTimeout(timer);
402
+ status.state = 'complete';
403
+ options.onProgress?.({ type: 'worker-complete', id: task.id, label: task.label });
404
+ eventBus.emit(Events.AGENT_PLAN_STEP_UPDATE, {
405
+ planId: job.id,
406
+ stepId: task.id,
407
+ state: 'complete',
408
+ label: task.label,
409
+ });
410
+ return { id: task.id, label: task.label, ...result };
411
+ } catch (workerErr) {
412
+ clearTimeout(timer);
413
+ if (controller.signal.aborted) throw workerErr;
414
+ console.warn(`[Kiku Research] Worker ${task.label} failed or timed out:`, workerErr?.message || workerErr);
415
+ status.state = 'failed';
416
+ status.error = workerErr?.message || 'Worker timed out';
417
+ return { id: task.id, label: task.label, answer: '', error: status.error, toolCalls: [] };
418
+ } finally {
419
+ clearTimeout(timer);
420
+ controller.signal.removeEventListener('abort', onParentAbort);
421
+ }
179
422
  });
180
423
 
181
424
  const reports = settled.map((entry, index) => {
@@ -188,19 +431,49 @@ export class ResearchOrchestrator {
188
431
  });
189
432
  if (controller.signal.aborted) throw controller.signal.reason || new DOMException('Aborted', 'AbortError');
190
433
 
434
+ // Extract all real URLs discovered across all tool calls
435
+ const realToolUrls = extractAllRealUrls(reports.flatMap(r => (r.toolCalls || []).map(tc => tc.result)));
436
+ const realToolUrlSet = new Set(realToolUrls);
437
+ const recordRealUrls = (found) => {
438
+ for (const u of found) {
439
+ if (!realToolUrlSet.has(u)) {
440
+ realToolUrlSet.add(u);
441
+ realToolUrls.push(u);
442
+ }
443
+ }
444
+ };
445
+ for (const promise of sharedToolCache.values()) {
446
+ try {
447
+ const res = await promise;
448
+ recordRealUrls(extractAllRealUrls([res]));
449
+ } catch {}
450
+ }
451
+
191
452
  const usable = reports.filter(report => report.answer);
192
453
  if (usable.length === 0) {
193
454
  const { webExtract } = await import('./webSearch.js');
194
455
  const fallbackRes = await webExtract(query);
195
456
  if (fallbackRes?.text) {
196
457
  usable.push({ label: 'Live Web Intelligence', answer: fallbackRes.text, sources: fallbackRes.sources || [] });
458
+ recordRealUrls(extractAllRealUrls([fallbackRes]));
197
459
  } else {
198
460
  throw new Error('All research workers failed and no web intelligence was returned.');
199
461
  }
200
462
  }
463
+
464
+ usable.forEach(report => {
465
+ if (report.answer) {
466
+ report.answer = expandTruncatedUrlsInText(report.answer, realToolUrls);
467
+ }
468
+ });
469
+
201
470
  const synthesisPrompt = options.mode === 'verify'
202
- ? 'Return a clear verdict: VERIFIED, UNVERIFIED, CONFLICTING, or FALSE. Explain briefly and cite only URLs present in the reports.'
203
- : 'Synthesize a comprehensive, detailed, and polished answer with verified facts. Cite only URLs present in the reports.';
471
+ ? 'Return a clear verdict: VERIFIED, UNVERIFIED, CONFLICTING, or FALSE. Explain briefly and cite only valid source URLs. Never truncate URLs with "..." or elide them.'
472
+ : `Synthesize a comprehensive, detailed, and polished answer with verified facts.
473
+ CRITICAL FORMATTING DIRECTIVE FOR SOURCES AND TABLES:
474
+ - In any markdown tables, lists, or citations, ALWAYS output the FULL, COMPLETE, UNTRUNCATED URL.
475
+ - NEVER shorten, elide, or truncate URLs with "..." (e.g. NEVER write "https://domain.com/play/..." or "https://...").
476
+ - Fans and users MUST be able to copy, click, or browse each source URL directly. Every URL in tables and lists must be 100% complete and fully formed.`;
204
477
  const synthesis = await this.client.chat({
205
478
  messages: [
206
479
  { role: 'system', content: `You are the Kiku research editor. ${synthesisPrompt} Never invent a source, date, title, or URL. Match the language of the question. Do not reveal hidden reasoning.` },
@@ -210,8 +483,13 @@ export class ResearchOrchestrator {
210
483
  stream: false,
211
484
  signal: controller.signal,
212
485
  });
213
- const answer = synthesis?.content || usable.map(report => `### ${report.label}\n${report.answer}`).join('\n\n');
214
- const sources = uniqueUrls(usable.map(report => report.answer).join('\n'));
486
+
487
+ const rawAnswer = synthesis?.content || usable.map(report => `### ${report.label}\n${report.answer}`).join('\n\n');
488
+ const answer = expandTruncatedUrlsInText(rawAnswer, realToolUrls);
489
+ const combinedText = usable.map(report => report.answer).join('\n') + '\n' + answer;
490
+ const rawSources = uniqueUrls(combinedText, realToolUrls);
491
+ const sources = await filterAliveSources(rawSources, realToolUrls, options);
492
+
215
493
  const result = {
216
494
  id: job.id,
217
495
  mode: options.mode || 'research',
@@ -231,6 +509,7 @@ export class ResearchOrchestrator {
231
509
  });
232
510
  return result;
233
511
  } finally {
512
+ clearTimeout(overallTimer);
234
513
  options.signal?.removeEventListener('abort', abortParent);
235
514
  if (this.active?.id === job.id) {
236
515
  this.active = null;
@@ -0,0 +1,77 @@
1
+ export class RoutingInterceptor {
2
+ constructor() {
3
+ this.historyCallSignatures = new Set();
4
+ }
5
+
6
+ interceptToolCall(toolCall, turnIndex = 0) {
7
+ if (!toolCall) return { valid: false, reason: 'Empty tool call' };
8
+
9
+ const name = toolCall.name || toolCall.function?.name || '';
10
+ const rawParams = toolCall.params || toolCall.function?.arguments || {};
11
+ let params = rawParams;
12
+ if (typeof params === 'string') {
13
+ try { params = JSON.parse(params); } catch { params = {}; }
14
+ }
15
+
16
+ const signature = `${name}:${JSON.stringify(params)}`;
17
+
18
+
19
+ if (name === 'global_search' && turnIndex === 0) {
20
+ const query = (params.query || '').toLowerCase();
21
+
22
+ if (/仙剑|花间令|剧|drama|role|costume|character|เรื่อง|ซีรีส์|ละคร|ตัวละคร|บทบาท|in blossom|sword and fairy/i.test(query)) {
23
+ return {
24
+ rerouted: true,
25
+ replacement: { name: 'search_works', params: { query: params.query } },
26
+ reason: 'Auto-rerouted from illegal first-turn global_search to search_works'
27
+ };
28
+ }
29
+
30
+
31
+ if (/view from the wall|เพลง|song|single|ost|title|壁上观|如纱|不苦|find the way/i.test(query)) {
32
+ return {
33
+ rerouted: true,
34
+ replacement: { name: 'search_discography', params: { query: params.query } },
35
+ reason: 'Auto-rerouted from illegal first-turn global_search to search_discography'
36
+ };
37
+ }
38
+
39
+
40
+ if (/award|honor|รางวัล|เกียรติยศ|东方风云榜|风云榜|weibo night/i.test(query)) {
41
+ return {
42
+ rerouted: true,
43
+ replacement: { name: 'get_awards', params: { query: params.query } },
44
+ reason: 'Auto-rerouted from illegal first-turn global_search to get_awards'
45
+ };
46
+ }
47
+ }
48
+
49
+ if (name === 'get_brand_endorsements') {
50
+ const brandParam = String(params.brand || params.query || '').toLowerCase();
51
+ if (/by my poi|be my poi|\bpoi\b|เพลง|mv|video|single|song|ost|stage/i.test(brandParam)) {
52
+ return {
53
+ rerouted: true,
54
+ replacement: { name: 'get_music_videos', params: { query: params.brand || params.query } },
55
+ reason: 'Auto-rerouted from misrouted get_brand_endorsements to get_music_videos for media/song query'
56
+ };
57
+ }
58
+ }
59
+
60
+
61
+ if (this.historyCallSignatures.has(signature)) {
62
+ return {
63
+ aborted: true,
64
+ reason: 'Duplicate call signature detected. Forcing synthesis.'
65
+ };
66
+ }
67
+
68
+ this.historyCallSignatures.add(signature);
69
+ return { valid: true, toolCall };
70
+ }
71
+
72
+ reset() {
73
+ this.historyCallSignatures.clear();
74
+ }
75
+ }
76
+
77
+ export const routingInterceptor = new RoutingInterceptor();
@@ -0,0 +1,15 @@
1
+ export const RUMORED_OR_EXTERNAL_PROJECTS = [
2
+ {
3
+ id: 'qi-xing-cai',
4
+ keywords: ['七星彩', 'qi xiang cai', 'qi xing cai', 'ชีซิงไฉ่', 'seven star color'],
5
+ titleCn: '七星彩',
6
+ titleEn: 'Qi Xing Cai (Seven Star Color)',
7
+ status: 'rumored_casting',
8
+ producer: 'Youku (优酷) x Stellar Media (恒星引力传媒)',
9
+ originalNovel: '《七星彩》 by 明月珰 (Mingyue Dang)',
10
+ leads: '纪澄 (Ji Cheng) & 沈彻 (Shen Che)',
11
+ details: 'โปรเจกต์ซีรีส์จีนโบราณฟอร์มยักษ์ของ Youku ร่วมกับค่ายเหิงซิงอิ่นลี่ (ผู้สร้างของรักของข้า Love Between Fairy and Devil) ดัดแปลงจากนิยายของหมิงเยว่ตัง เล่าเรื่องราวของจี้เฉิง บุตรสาวตระกูลพ่อค้าที่ใช้ปฏิภาณไหวพริบเอาตัวรอดจนได้พบกับแม่ทัพเสิ่นเช่อ',
12
+ buzzReason: 'เหตุผลที่ชาวเน็ตให้ความสนใจอย่างมากคือ กระแสข่าวลือแคสติ้งนักแสดงนำ (Casting Rumors / 选角瓜) โดยมีการลือชื่อนักแสดงหญิงแถวหน้าหลายคนรวมถึง จวีจิ้งอี, หลูอวี้เสี่ยว ฯลฯ แต่ปัจจุบันยังไม่มีการประกาศยืนยันนักแสดงทางการจากทางค่ายหรือสตูดิโอ',
13
+ disambiguation: 'คนละผลงานกับซีรีส์ 《千香》 (The Eternal Fragrance / พันกลิ่นบุปผา) ที่จวีจิ้งอีนำแสดงร่วมกับ ซ่งเวยหลง'
14
+ }
15
+ ];