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
@@ -1,6 +1,7 @@
1
1
  import { AgentStateMachine, AgentStates } from './agentState.js';
2
+ import { buildSystemPrompt } from './prompts.js';
2
3
  import { config } from './config.js';
3
- import { contextCompactor } from './contextCompactor.js';
4
+ import { contextCompactor, compactToolResultForContext, estimateTextTokens, estimateTokenCount } from './contextCompactor.js';
4
5
  import { createAbortError, isAbortError } from './errors.js';
5
6
  import { Events, eventBus } from './eventBus.js';
6
7
  import { AgentJobManager } from './jobManager.js';
@@ -12,15 +13,22 @@ import { sanitizeQuery, sanitizeResponse } from './sanitizer.js';
12
13
  import { AgentSession } from './session.js';
13
14
  import { ResearchSubagent } from './subagent.js';
14
15
  import { SuggestionEngine } from './suggestions.js';
16
+ import { buildTextToolProtocolDirective, extractTopLevelJsonObjects, MALFORMED_TOOL_CALL_FEEDBACK, parseTextToolCallsFromContent, stripToolCallBlocks } from './textToolProtocol.js';
15
17
  import { DynamicToolRouter } from './toolRouter.js';
16
18
  import { executeTool, prepareToolArguments, toolsSchema, isBuiltinTool } from './tools.js';
17
19
  import { detectAmbiguities, isQueryTooVague, emitClarifyingQuestions, calculateConfidence, generateClarifyingQuestions, buildClarificationContext } from './clarifyingQuestions.js';
18
20
  import { ClarificationAnalytics, ClarificationConfig } from './clarificationAnalytics.js';
19
21
  import { calculateTokenCost } from './telemetry.js';
20
22
  import { extractCitations, resolveCitationReference } from './citations.js';
23
+ import { resolveQueryEntities } from './catalogEntityResolver.js';
24
+ import { TokenTracker } from './tokenTracker.js';
25
+ import { RoutingInterceptor } from './routingInterceptor.js';
21
26
 
22
- export function detectDominantLanguage(text) {
23
- if (!text || typeof text !== 'string') return 'th';
27
+
28
+ const OUTPUT_TOKEN_BUDGET = Math.max(512, Number(process.env.KIKU_MAX_OUTPUT_TOKENS) || 4096);
29
+
30
+ export function detectDominantLanguage(text, sessionLang = null) {
31
+ if (!text || typeof text !== 'string') return sessionLang || 'th';
24
32
 
25
33
  const clean = text
26
34
  .replace(/\[SYSTEM\s+[A-Z]+:[^\]]*\]/gi, '')
@@ -32,21 +40,19 @@ export function detectDominantLanguage(text) {
32
40
 
33
41
  const targetText = clean || text;
34
42
 
35
- const thaiMatches = targetText.match(/[\u0E00-\u0E7F]/g) || [];
36
- const thaiCount = thaiMatches.length;
37
-
38
- const chineseMatches = targetText.match(/[\u4E00-\u9FA5]/g) || [];
39
- const chineseCount = chineseMatches.length;
40
-
41
- const kanaMatches = targetText.match(/[\u3040-\u30FF]/g) || [];
42
- const kanaCount = kanaMatches.length;
43
-
44
- const hangulMatches = targetText.match(/[\uAC00-\uD7AF]/g) || [];
45
- const hangulCount = hangulMatches.length;
46
-
47
- const englishMatches = targetText.match(/[a-zA-Z]+/g) || [];
48
- const englishCount = englishMatches.join('').length;
49
-
43
+ let thaiCount = 0;
44
+ let chineseCount = 0;
45
+ let kanaCount = 0;
46
+ let hangulCount = 0;
47
+ let englishCount = 0;
48
+ for (const ch of targetText) {
49
+ const code = ch.codePointAt(0);
50
+ if (code >= 0x0E00 && code <= 0x0E7F) thaiCount++;
51
+ else if (code >= 0x3040 && code <= 0x30FF) kanaCount++;
52
+ else if (code >= 0x4E00 && code <= 0x9FA5) chineseCount++;
53
+ else if (code >= 0xAC00 && code <= 0xD7AF) hangulCount++;
54
+ else if ((code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A)) englishCount++;
55
+ }
50
56
 
51
57
  if (kanaCount > 0) {
52
58
  return 'ja';
@@ -56,15 +62,31 @@ export function detectDominantLanguage(text) {
56
62
  return 'ko';
57
63
  }
58
64
 
59
- if (thaiCount >= chineseCount && thaiCount >= englishCount && thaiCount > 0) {
60
- return 'th';
61
- } else if (chineseCount > thaiCount && chineseCount >= englishCount && chineseCount > 0) {
65
+
66
+ if (thaiCount > 0) {
67
+ if (thaiCount >= 2 || thaiCount >= chineseCount) {
68
+ return 'th';
69
+ }
70
+ }
71
+
72
+ if (chineseCount > 0 && chineseCount >= thaiCount && chineseCount >= englishCount) {
62
73
  return 'zh';
63
- } else if (englishCount > 0) {
74
+ }
75
+
76
+ if (thaiCount > 0) {
77
+ return 'th';
78
+ }
79
+
80
+
81
+ if (sessionLang && sessionLang !== 'en' && (targetText.match(/[a-zA-Z]+/g) || []).length <= 3 && !/\b(what|who|when|where|why|how|which|can|tell|give|list|show|is|are|was|were)\b/i.test(targetText)) {
82
+ return sessionLang;
83
+ }
84
+
85
+ if (englishCount > 0) {
64
86
  return 'en';
65
87
  }
66
88
 
67
- return 'th';
89
+ return sessionLang || 'th';
68
90
  }
69
91
 
70
92
  function stableToolSignature(toolName, args) {
@@ -78,11 +100,81 @@ function stableToolSignature(toolName, args) {
78
100
  return `${toolName}:${JSON.stringify(sortValue(args || {}))}`;
79
101
  }
80
102
 
81
- export class KikuAgent {
82
- constructor(options = {}) {
83
- this.allowedToolNames = options.allowedToolNames
84
- ? new Set(Array.from(options.allowedToolNames, String))
85
- : null;
103
+
104
+ export { extractTopLevelJsonObjects };
105
+
106
+ const REPEAT_LOOP_THRESHOLD = 3;
107
+
108
+
109
+ function summarizeToolDescription(description, maxChars = 160) {
110
+ const text = String(description || '').replace(/\s+/g, ' ').trim();
111
+ if (text.length <= maxChars) return text;
112
+ const cut = text.slice(0, maxChars);
113
+ const lastStop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '));
114
+ return lastStop > 60 ? cut.slice(0, lastStop + 1) : `${cut.trim()}…`;
115
+ }
116
+
117
+
118
+ export function stripInjectedDirectives(text) {
119
+ let t = String(text || '');
120
+ t = t.replace(/\[SYSTEM DIRECTIVE[\s\S]*$/i, '');
121
+ t = t.replace(/\[SYSTEM CLARIFICATION\][\s\S]*$/i, '');
122
+ t = t.replace(/\[CONTEXT GROUNDING:[^\]]*\]/gi, '');
123
+ t = t.replace(/\[RESTORED SESSION TOOL CONTEXT\][\s\S]*$/i, '');
124
+ return t.trim();
125
+ }
126
+
127
+ const AUTO_RESEARCH_KEYWORDS = [
128
+
129
+ 'วิเคราะห์', 'เปรียบเทียบ', 'เจาะลึก', 'ลงลึก', 'วิจัย', 'ค้นคว้า', 'รายงาน', 'ข่าวลือ', 'ตรวจสอบ', 'รวบรวม', 'ครบถ้วน',
130
+
131
+ 'analyze', 'analysis', 'compare', 'research', 'investigate', 'deep dive', 'in depth', 'in-depth', 'comprehensive', 'everything about', 'report on', 'rumor',
132
+
133
+ '分析', '比较', '對比', '调查', '研究', '深入', '全面',
134
+ ];
135
+
136
+ export function scoreResearchComplexity(prompt) {
137
+ const text = stripInjectedDirectives(prompt);
138
+ const signals = [];
139
+ let score = 0;
140
+ if (!text) return { score, signals, strippedLength: 0 };
141
+
142
+ if (text.length >= 240) { score += 1; signals.push('length'); }
143
+ if (text.length >= 600) { score += 1; signals.push('very-long'); }
144
+
145
+ const questionMarks = (text.match(/[??]/g) || []).length;
146
+ if (questionMarks >= 2) { score += 1; signals.push('multi-question'); }
147
+
148
+ const listMarkers = (text.match(/(^|\n)\s*(?:\d+[.)]|[-•*])\s+\S/g) || []).length;
149
+ if (listMarkers >= 2) { score += 1; signals.push('list'); }
150
+
151
+ const lower = text.toLowerCase();
152
+ const matchedKeywords = AUTO_RESEARCH_KEYWORDS.filter(k => lower.includes(k));
153
+ if (matchedKeywords.length >= 1) { score += 1; signals.push(`keywords:${matchedKeywords.slice(0, 3).join('|')}`); }
154
+ if (matchedKeywords.length >= 3) { score += 1; signals.push('keyword-rich'); }
155
+
156
+ return { score, signals, strippedLength: text.length };
157
+ }
158
+
159
+
160
+ export function shouldAutoResearch(prompt, options = {}) {
161
+ const mode = String(options.mode ?? 'auto').toLowerCase();
162
+ if (mode === 'off') return false;
163
+ const { score } = scoreResearchComplexity(prompt);
164
+ if (mode === 'on') return score >= 1;
165
+ return score >= (Number(options.threshold) || 3);
166
+ }
167
+
168
+ function buildAutoResearchDirective(complexity) {
169
+ const signalText = (complexity.signals || []).slice(0, 4).join(', ');
170
+ return `[SYSTEM DIRECTIVE: AUTO DEEP RESEARCH] The user's query is complex and multi-part (complexity score ${complexity.score}${signalText ? ` — ${signalText}` : ''}). Do NOT answer it with a few quick tool calls. You MUST begin by calling run_deep_research ONCE with a focused, self-contained question derived from the user's request. After the research report returns, synthesize the final answer from its cited findings. If run_deep_research fails or returns insufficient data, fill the gaps with web_search/web_extract instead of leaving any section unanswered.`;
171
+ }
172
+
173
+ export class KikuAgent {
174
+ constructor(options = {}) {
175
+ this.allowedToolNames = options.allowedToolNames
176
+ ? new Set(Array.from(options.allowedToolNames, String))
177
+ : null;
86
178
  this.client = options.client || new LLMClient({
87
179
  provider: options.provider || config.activeProvider,
88
180
  baseUrl: options.baseUrl,
@@ -94,13 +186,18 @@ export class KikuAgent {
94
186
  this.session = new AgentSession({
95
187
  thinkingMode: options.thinkingMode || 'hide',
96
188
  sessionId: options.sessionId,
97
- storageKey: options.sessionStorageKey,
98
- storageFile: options.sessionStorageFile,
99
- persistence: options.sessionPersistence !== false,
189
+ storageKey: options.sessionStorageKey,
190
+ storageFile: options.sessionStorageFile,
191
+ persistence: options.sessionPersistence !== false,
100
192
  });
101
193
  this.memory = options.memory || new AgentMemory();
194
+ this.activeRunId = options.activeRunId || options.runId || null;
195
+ if (this.activeRunId && typeof this.memory.setActiveRunId === 'function') {
196
+ this.memory.setActiveRunId(this.activeRunId);
197
+ }
102
198
  this.mcpManager = options.mcpManager || new McpClientManager();
103
199
  this.router = new DynamicToolRouter(this.getAllToolsSchema());
200
+ this.routingInterceptor = new RoutingInterceptor();
104
201
  this.research = new ResearchOrchestrator({ client: this.client });
105
202
  this.suggestions = new SuggestionEngine({ client: this.client, memory: this.memory });
106
203
  this.jobs = new AgentJobManager();
@@ -110,6 +207,8 @@ export class KikuAgent {
110
207
  this.initHistory();
111
208
  this.restoredMessageCount = 0;
112
209
  this.lastCitations = [];
210
+ this.lastQueryMetrics = null;
211
+ this.queryMetricsHistory = [];
113
212
 
114
213
  this.stateMachine = new AgentStateMachine();
115
214
 
@@ -133,7 +232,12 @@ export class KikuAgent {
133
232
  await this.mcpManager.loadPersistedServers();
134
233
  this.router.setTools(this.getAllToolsSchema());
135
234
  this.systemPrompt = this.buildSystemPrompt();
136
- } catch { }
235
+ } catch (err) {
236
+
237
+ this.mcpInitError = err?.message || String(err);
238
+ console.warn('[Kiku] Failed to load persisted MCP servers:', this.mcpInitError);
239
+ eventBus.emit(Events.AGENT_QUERY_ERROR, { type: 'mcp_init', error: this.mcpInitError });
240
+ }
137
241
  }
138
242
  if (!this.conversationHistory || this.conversationHistory.length === 0) {
139
243
  this.initHistory();
@@ -360,6 +464,52 @@ export class KikuAgent {
360
464
  return this.endSession();
361
465
  }
362
466
 
467
+ /**
468
+ * Summarize tool evidence from persisted session messages. Raw tool payloads
469
+ * are not rehydrated into LLM history (they violate tool-call pairing after
470
+ * a restart and bloat the window), so the model gets one compact evidence
471
+ * line per earlier query instead.
472
+ */
473
+ _rehydrateSessionMessages() {
474
+ const evidenceLines = [];
475
+ const messages = this.session.messages || [];
476
+ for (let i = 0; i < messages.length; i++) {
477
+ const msg = messages[i];
478
+ if (msg.role !== 'assistant') continue;
479
+ const toolParts = (msg.parts || []).filter(p => p.type === 'tool');
480
+ if (toolParts.length === 0) continue;
481
+
482
+ const textPart = (msg.parts || []).find(p => p.type === 'text');
483
+ let userQuestion = '';
484
+ for (let j = i - 1; j >= 0; j--) {
485
+ if (messages[j].role === 'user') {
486
+ const part = (messages[j].parts || []).find(p => p.type === 'text');
487
+ userQuestion = String(part?.content || '').replace(/\s+/g, ' ').trim().slice(0, 80);
488
+ break;
489
+ }
490
+ }
491
+ const toolSummary = toolParts
492
+ .map(p => {
493
+ const status = p.status || p.result?.status || 'ok';
494
+ const count = p.result?.count ?? p.result?.returnedCount;
495
+ return `${p.name || 'tool'}(${status}${count !== undefined ? `, ${count}` : ''})`;
496
+ })
497
+ .join(', ');
498
+ const answerSnippet = String(textPart?.content || '').replace(/\s+/g, ' ').trim().slice(0, 100);
499
+ evidenceLines.push(`- Q: "${userQuestion || '(no text)'}" → ${toolSummary}${answerSnippet ? ` → answered: "${answerSnippet}"` : ''}`);
500
+ }
501
+ return evidenceLines;
502
+ }
503
+
504
+ _pushRestoredToolContext() {
505
+ const evidenceLines = this._rehydrateSessionMessages();
506
+ if (evidenceLines.length === 0) return;
507
+ this.conversationHistory.push({
508
+ role: 'system',
509
+ content: `[RESTORED SESSION TOOL CONTEXT] The queries below were already answered with verified tool results earlier in this session (the raw tool outputs are no longer in history):\n${evidenceLines.slice(-20).join('\n')}\nTreat these as previously verified facts. Re-call the relevant tools only if the user needs fresh or deeper detail.`
510
+ });
511
+ }
512
+
363
513
  async loadSession() {
364
514
  const loaded = await this.session.loadFromDisk();
365
515
  if (loaded && this.session.messages.length > 0) {
@@ -389,6 +539,7 @@ export class KikuAgent {
389
539
  }
390
540
  }
391
541
  }
542
+ this._pushRestoredToolContext();
392
543
  this.restoredMessageCount = this.session.messages.length;
393
544
  }
394
545
  }
@@ -441,6 +592,7 @@ export class KikuAgent {
441
592
  }
442
593
  }
443
594
  }
595
+ this._pushRestoredToolContext();
444
596
  this.restoredMessageCount = this.session.messages.length;
445
597
  eventBus.emit(Events.SESSION_SAVED, { sessionId: this.session.id });
446
598
  return archived;
@@ -509,21 +661,100 @@ export class KikuAgent {
509
661
  return { hydrated: true, messageCount: normalized.length };
510
662
  }
511
663
 
512
- applySlidingWindow() {
664
+ _isLocalProvider() {
665
+ const base = String(this.client?.baseUrl || '');
666
+ if (/localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/i.test(base)) return true;
667
+ return this.client?.provider === 'lmstudio' && !base;
668
+ }
669
+
670
+ async applySlidingWindow() {
513
671
  if (this.conversationHistory.length > this.slidingWindowSize + 1) {
514
- this.conversationHistory = contextCompactor.compact(this.conversationHistory, {
672
+ const options = {
515
673
  maxTokens: 24000,
516
674
  keepRecentMessages: this.slidingWindowSize,
517
- });
675
+ };
676
+ if (!this._isLocalProvider()) {
677
+ options.summarizer = (older) => this._llmSummarizeOlderMessages(older);
678
+ }
679
+ this.conversationHistory = await contextCompactor.compactAsync(this.conversationHistory, options);
680
+ }
681
+ }
682
+
683
+ /** LLM summary of the dropped window (cloud providers only) — falls back to
684
+ * the heuristic summary inside the compactor when this fails. */
685
+ async _llmSummarizeOlderMessages(olderMessages) {
686
+ const transcript = (olderMessages || [])
687
+ .map(m => {
688
+ const role = m.role === 'tool' ? `tool(${m.name || '?'})` : m.role;
689
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? m.tool_calls ?? '');
690
+ return `${role}: ${content.slice(0, 400)}`;
691
+ })
692
+ .join('\n')
693
+ .slice(0, 6000);
694
+
695
+ if (!transcript.trim()) return '';
696
+ const res = await Promise.race([
697
+ this.client.chat({
698
+ messages: [
699
+ { role: 'system', content: 'Summarize the following conversation excerpt for an assistant context window. Capture what the user asked, which tools were used, and the key verified facts. Maximum 120 words. Output only the summary text.' },
700
+ { role: 'user', content: transcript }
701
+ ],
702
+ temperature: 0.1,
703
+ maxTokens: 300,
704
+ stream: false
705
+ }),
706
+ new Promise((_, reject) => setTimeout(() => reject(new Error('summary timeout')), 20_000))
707
+ ]);
708
+ return String(res?.content || '').trim();
709
+ }
710
+
711
+ /**
712
+ * Hard context-budget guard: KIKU_CONTEXT_LIMIT becomes a real budget instead
713
+ * of telemetry. Before each LLM call, if the estimated prompt exceeds 85% of
714
+ * the limit, replace the oldest tool results with omission stubs — tool
715
+ * payloads are the bulkiest and least-likely-to-be-reused part of history.
716
+ */
717
+ _enforceContextBudget() {
718
+ const rawLimit = Number(process.env.KIKU_CONTEXT_LIMIT);
719
+ if (!Number.isFinite(rawLimit) || rawLimit <= 0) return false;
720
+ const budget = Math.floor(rawLimit * 0.85);
721
+
722
+ let total = 0;
723
+ for (const msg of this.conversationHistory) {
724
+ if (typeof msg._tokenEstimate !== 'number') {
725
+ msg._tokenEstimate = estimateTokenCount([msg]);
726
+ }
727
+ total += msg._tokenEstimate;
518
728
  }
729
+ if (total <= budget) return false;
730
+
731
+ const omitted = JSON.stringify({
732
+ authoritative: false,
733
+ status: 'omitted',
734
+ message: 'Older tool result was omitted to fit the context window; its facts are no longer visible. Re-call the tool only if essential.'
735
+ });
736
+ const omittedTokens = estimateTextTokens(omitted);
737
+ let trimmed = false;
738
+
739
+ for (const msg of this.conversationHistory) {
740
+ if (total <= budget) break;
741
+ if (msg.role !== 'tool' || msg._omittedForContext) continue;
742
+ const prev = msg._tokenEstimate || estimateTokenCount([msg]);
743
+ total -= prev - omittedTokens;
744
+ msg.content = omitted;
745
+ msg._omittedForContext = true;
746
+ msg._tokenEstimate = omittedTokens;
747
+ trimmed = true;
748
+ }
749
+ return trimmed;
519
750
  }
520
751
 
521
- getAllToolsSchema() {
522
- const mcpTools = this.mcpManager ? this.mcpManager.getToolsSchema() : [];
523
- const allTools = [...toolsSchema, ...mcpTools];
524
- if (!this.allowedToolNames) return allTools;
525
- return allTools.filter(tool => this.allowedToolNames.has(tool.function?.name));
526
- }
752
+ getAllToolsSchema() {
753
+ const mcpTools = this.mcpManager ? this.mcpManager.getToolsSchema() : [];
754
+ const allTools = [...toolsSchema, ...mcpTools];
755
+ if (!this.allowedToolNames) return allTools;
756
+ return allTools.filter(tool => this.allowedToolNames.has(tool.function?.name));
757
+ }
527
758
 
528
759
  async connectMcp(name, serverConfig) {
529
760
  const result = await this.mcpManager.connect(name, serverConfig);
@@ -586,180 +817,18 @@ export class KikuAgent {
586
817
  return results;
587
818
  }
588
819
 
589
- buildSystemPrompt() {
590
- const allTools = this.getAllToolsSchema();
591
- const today = new Date().toISOString().slice(0, 10);
592
- const toolsSummary = allTools.map(t => {
593
- const f = t.function;
594
- const paramKeys = Object.keys(f.parameters?.properties || {}).join(', ');
595
- return `- ${f.name}(${paramKeys}): ${f.description}`;
596
- }).join('\n');
597
-
598
- return `${config.agent.systemPersona}
599
-
600
- === AVAILABLE KNOWLEDGE & LIVE TOOLS ===
601
- ${toolsSummary}
602
-
603
- === DYNAMIC MULTILINGUAL & RESPONSE RULES (CRITICAL) ===
604
-
605
- 0. NO SYSTEM LEAK (ABSOLUTE RULE):
606
- - NEVER include "Thinking Process", "Tool Calling", "Action:", "Action Input:", function names, parameter schemas, JSON tool call blocks, or any internal reasoning in your response text.
607
- - NEVER include advertisements, sponsor mentions, or promotional content.
608
- - Your response must be CLEAN user-facing content ONLY.
609
- - Think silently using tools, then present ONLY the final polished answer.
610
-
611
- 1. LANGUAGE MIRRORING:
612
- - THAI ➔ polite Thai (ใช้ ค่ะ/นะคะ เสมอ)
613
- - CHINESE ➔ fluent, polite Chinese (简体/繁体中文)
614
- - ENGLISH ➔ polished, professional English
615
- - MIXED ➔ Match dominant language, keep bilingual titles
616
-
617
- 2. REAL YOUTUBE & MEDIA LINKS:
618
- - Direct Watch: https://www.youtube.com/watch?v=VIDEO_ID
619
- - Search: https://www.youtube.com/results?search_query=QUERY
620
- - Also Bilibili, QQ Music, Youku when applicable
621
- - NEVER use fake placeholder IDs
622
-
623
- 3. FACT ACCURACY & NO DUPLICATION:
624
- - Always use tools to verify facts before answering. Do NOT hallucinate.
625
- - Current date is ${today}. Prioritize current and recent projects when relevant.
626
- - Never repeat the same statement. Sort newest first.
627
-
628
- 3A. CONVERSATION TOPIC ANCHOR:
629
- - Resolve short or ambiguous follow-ups (for example "ทำไมน่าจับตามอง?", "งานนี้", "why is it notable?") against the most recent user topic and assistant answer.
630
- - The current follow-up inherits that topic. Do not default the subject to Ju Jingyi merely because the persona or knowledge base focuses on her.
631
- - A persona changes voice and tone only; it never replaces the user's subject.
632
- - If the reference remains genuinely ambiguous after reading recent history, ask one focused clarification question instead of guessing.
633
-
634
- 3B. FUTURE-EVENT CLAIM SAFETY:
635
- - Treat any event after today's date as scheduled or reported, not completed fact.
636
- - Never assert an exact future date, invitation, guest list, or attendance without checking a current official source with a live web tool.
637
- - Never carry a date, guest list, or attendance status from one annual edition into another year.
638
- - When official confirmation is unavailable, say clearly that the detail is unconfirmed and subject to change.
639
-
640
- 4. RESPONSE STYLE:
641
- - NEVER start with template greetings — dive DIRECTLY into the answer.
642
- - Format with Markdown headers (###), bold (**text**), bullet points (•).
643
- - Emoji are optional: use at most one contextually relevant emoji per response and never repeat them across headings or bullets.
644
- - Match answer depth to what the user asked.
645
- - Separate catalog facts from interpretation. Clearly frame acting observations as analysis unless a cited review supports them.
646
- - Do not infer that newer means deeper, more realistic, or better acted. Compare concrete role demands, narrative agency, genre, and verified scenes instead.
647
- - Omit catalog ratings from analytical conclusions unless the tool output names the rating source and scale.
648
- - Avoid unsupported superlatives or career verdicts (for example "unrivalled", "top-tier", or "completely transcended idol status").
649
- - In Blossom (花间令): Ju Jingyi portrays Yang Caiwei's identity in Shangguan Zhi's appearance after the face-swap premise; do not describe this as two unrelated roles throughout the series.
650
- - The Legend of White Snake (新白娘子传奇): Bai Suzhen is the white-snake spirit in human form and Xu Xian is the human man; never reverse this relationship.
651
- - On an explicit continuation, answer the new angle without restating the previous response.
652
-
653
- 5. SMART TOOL SELECTION (MANDATORY LOCAL DATABASE FIRST):
654
- - ALWAYS call LOCAL built-in database tools FIRST. They query the rich, local catalog database directly in-process and are the authoritative source of truth.
655
- - Videos/stages/audio ➔ search_discography, get_music_videos, get_media_links, play_music
656
- - Songs/OSTs ➔ search_discography
657
- - Dramas/roles ➔ search_works, get_work_detail, get_character_roles
658
- - Profile/fandom ➔ get_profile_and_fandom
659
- - Brands ➔ get_brand_endorsements
660
- - Awards ➔ get_awards
661
- - Career timeline ➔ get_career_timeline
662
- - Local files ➔ read_local_file, list_directory, get_system_stats
663
- - MCP tools (mcp_*) ➔ Secondary only; only invoke when user explicitly mentions MCP (@mcp) or when specialized external services are required.
664
- - News/external facts ➔ web_search or web_extract
665
- - Deep multi-source research ➔ web_extract (search + auto-fetch)
666
- - Read webpage content ➔ fetch_webpage
667
- - Fetch API/JSON ➔ fetch_json
668
- - Screenshot ➔ screenshot_webpage
669
-
670
- 6. AGGRESSIVE WEB SEARCH FALLBACK (CRITICAL):
671
- - If a local catalog tool (e.g. search_works, search_discography, get_brand_endorsements, get_awards) returns not_found, empty, or 0 results, DO NOT give up or guess. You MUST IMMEDIATELY call web_search or web_extract in the next step to look up live external facts online.
672
- - Combine local catalog data WITH live web search for complete, accurate, and up-to-date answers.
673
- - Use web_extract for comprehensive research on any topic.
674
-
675
- 7. LEGAL & CAREER KNOWLEDGE:
676
- - When discussing controversies, be specific and accurate:
677
- • Contract dispute: Star48/Siba Media supplementary agreement. JJY claims main contract ended June 2024, alleges forged signatures. Star48 claims until 2033.
678
- • Tax allegation: Tip-off report filed by Star48 side; JJY denies and confirms tax compliance.
679
- • Portrait/copyright cases: JJY has sued beauty clinics and brands for unauthorized image use (肖像权纠纷).
680
- - Use web_search or web_extract for latest verified information on legal matters.
681
-
682
- 8. DELIBERATE REASONING & AUTONOMOUS TOOL SELECTION (CRITICAL):
683
- - Think carefully about the complexity, goal, and nature of the query before deciding which tool to use:
684
- • Direct Specific Query ➔ Use direct catalog tools (search_works, search_discography, get_profile_and_fandom, get_awards, get_brand_endorsements, etc.).
685
- • Isolated In-Depth Subtask ➔ Call run_subagent with a specific task parameter to delegate an isolated iterative investigation with evidence and tool call tracing.
686
- • Complex Multi-Faceted Research ➔ Call run_deep_research with question when the user asks an open-ended, complex query that requires parallel multi-worker investigation across catalog, live web sources, and independent verifier with cited sources.
687
- • Rumors, Cast Leaks & Fact Verification ➔ Call verify_claim with claim when the user asks whether a rumor, statement, or unconfirmed news is true (returns VERIFIED, UNVERIFIED, CONFLICTING, or FALSE).
688
- • Teaching / Updating Response Preferences ➔ Call manage_skill with action 'learn' (with instruction), 'list', 'remove' (with skillId), 'clear', 'enable', or 'disable' when the user instructs how they want Kiku to answer in future conversations.
689
-
690
- 9. ANTI-HALLUCINATION GUARDRAILS (CRITICAL — MANDATORY COMPLIANCE):
691
-
692
- 0. INDEPENDENT VERIFICATION FIRST:
693
- - When asked about specific named entities (e.g., upcoming dramas, casts, titles, products, brand deals, real-world releases), do NOT generate an explanation based solely on keyword inference or assumed context.
694
- - You MUST cross-reference and verify the exact entity existence using your local catalog tools (search_works, search_discography, get_brand_endorsements, etc.) BEFORE writing the response.
695
- - If the local catalog has no match, you MUST call web_search or web_extract to verify externally before responding.
696
-
697
- 1. ZERO-ASSUMPTION ON UNVERIFIED ENTITIES:
698
- - If the subject/entity cannot be verified with official records (not found in catalog tools AND not found via web_search), explicitly state that no verified record exists.
699
- - Strictly avoid fabricating plausible generic narratives (e.g., generic tropes, placeholder plotlines, speculative casting, invented brand partnerships).
700
- - NEVER invent drama titles, song names, character roles, brand endorsements, or career milestones that are not backed by tool results.
701
-
702
- 2. FALLBACK & UNCERTAINTY PROTOCOL:
703
- - If the entity appears to be misspelled, fan fiction, or an unverified rumor, directly flag the discrepancy (including linguistic errors like incorrect Pinyin or romanization) rather than adopting the user's premise as true.
704
- - Always prioritize stating "No official information found in the database" over generating a complete but ungrounded description.
705
- - If tool results are ambiguous or partial, present only what is confirmed and clearly label uncertain parts as "unverified" or "not confirmed."
706
-
707
- 3. SOURCE ATTRIBUTION:
708
- - When stating facts, prefer answers that trace back to tool results. If a claim comes from local catalog, you may present it as confirmed data. If from web search, note it as external/recent information.
709
- - NEVER present fabricated information as if it were verified catalog data.
710
-
711
- 4. SELF-CHECK BEFORE FINAL ANSWER:
712
- - Before synthesizing your final response, mentally verify: "Does every factual claim in my answer have a corresponding tool result backing it up?"
713
- - If any claim lacks tool evidence, either (a) call the appropriate tool to verify it, or (b) remove it from your answer.
714
- - This applies to ALL entity types: dramas, songs, MVs, brands, awards, dates, character names, and career milestones.`;
820
+ buildSystemPrompt(scopedTools = null) {
821
+ const allTools = this.getAllToolsSchema ? this.getAllToolsSchema() : [];
822
+ const activeTools = scopedTools || allTools;
823
+ return buildSystemPrompt(activeTools, { isNativeToolCalling: true });
715
824
  }
716
825
 
717
- parseTextToolCalls(content) {
718
- if (!content) return [];
719
- const calls = [];
720
- const allTools = this.getAllToolsSchema();
721
-
722
- const jsonBlockRegex = /```(?:json)?\s*(\{[\s\S]*?\})\s*```/g;
723
- for (const match of content.matchAll(jsonBlockRegex)) {
724
- try {
725
- const parsed = JSON.parse(match[1]);
726
- const toolName = parsed.action || parsed.tool || parsed.name || parsed.function;
727
- const toolArgs = parsed.action_input || parsed.parameters || parsed.arguments || parsed.args || {};
728
- if (toolName && allTools.some(t => t.function.name === toolName)) {
729
- calls.push({
730
- id: `call_txt_${Date.now()}_${calls.length}`,
731
- function: {
732
- name: toolName,
733
- arguments: typeof toolArgs === 'string' ? toolArgs : JSON.stringify(toolArgs)
734
- }
735
- });
736
- }
737
- } catch {
738
- }
739
- }
740
-
741
- const actionRegex = /Action:\s*([a-zA-Z0-9_]+)\s*\nAction Input:\s*(\{[\s\S]*?\}|[^\n]+)/g;
742
- for (const match of content.matchAll(actionRegex)) {
743
- const toolName = match[1].trim();
744
- const rawInput = match[2].trim();
745
- if (allTools.some(t => t.function.name === toolName)) {
746
- let args = {};
747
- try {
748
- args = JSON.parse(rawInput);
749
- } catch {
750
- args = { query: rawInput };
751
- }
752
- calls.push({
753
- id: `call_act_${Date.now()}_${calls.length}`,
754
- function: {
755
- name: toolName,
756
- arguments: JSON.stringify(args)
757
- }
758
- });
759
- }
760
- }
826
+ _extractTextToolCalls(content) {
827
+ return parseTextToolCallsFromContent(content, this._activeToolSchema || this.getAllToolsSchema());
828
+ }
761
829
 
762
- return calls;
830
+ parseTextToolCalls(content) {
831
+ return this._extractTextToolCalls(content).calls;
763
832
  }
764
833
 
765
834
  async ask(userPrompt, callbacks = {}) {
@@ -792,12 +861,24 @@ ${toolsSummary}
792
861
  onTokenDelta = () => { },
793
862
  onFinalAnswer = () => { },
794
863
  onError = () => { },
864
+ onNotice = () => { },
865
+ systemDirectives = null,
795
866
  signal = null
796
867
  } = callbacks;
797
868
 
798
869
  const queryStartTime = Date.now();
799
870
  const queryId = `q_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
800
- const dominantLang = detectDominantLanguage(promptForLLM);
871
+ const dominantLang = detectDominantLanguage(promptForLLM, this.session?.dominantLanguage);
872
+ if (this.session) {
873
+ this.session.dominantLanguage = dominantLang;
874
+ }
875
+
876
+ const tokenTracker = new TokenTracker({
877
+ sessionId: this.session?.id || null,
878
+ modelId: this.client?.model || 'unknown',
879
+ contextLimit: Number(process.env.KIKU_CONTEXT_LIMIT) || 0,
880
+ });
881
+ tokenTracker.start();
801
882
 
802
883
  this.stateMachine.startQuery(queryId);
803
884
  eventBus.emit(Events.AGENT_QUERY_START, { queryId, prompt: promptForLLM });
@@ -809,6 +890,27 @@ ${toolsSummary}
809
890
  ];
810
891
  }
811
892
 
893
+ const isSecurityAttack = /(ผู้ใช้คนอื่น|หัวหน้าของเขา|แสดงประวัติส่วนตัวของเขา|ขอรหัส|password|secret|credential|token|api_?key|api\s*key|\.env|cookies\.txt|kiku_vip_keys|system\s+directive|system\s+prompt|prompt\s+leak|bypass|unauthorized|环境变量)/i.test(promptForLLM);
894
+ if (isSecurityAttack) {
895
+ const refusalMessage = dominantLang === 'zh'
896
+ ? "抱歉,无法提供系统配置、私有数据或敏感指令。"
897
+ : dominantLang === 'en'
898
+ ? "I cannot disclose system configurations, credentials, or personal private data."
899
+ : "ขออภัยค่ะ ไม่สามารถเปิดเผยข้อมูลการกำหนดค่าระบบ ข้อมูลประจำตัว หรือข้อมูลส่วนบุคคลได้";
900
+ this.session.createUserMessage(promptForLLM);
901
+ this.session.createAssistantMessage({ content: refusalMessage });
902
+ this.conversationHistory.push({ role: 'user', content: promptForLLM });
903
+ this.conversationHistory.push({ role: 'assistant', content: refusalMessage });
904
+ const totalDurationMs = Date.now() - queryStartTime;
905
+ onFinalAnswer(refusalMessage, { durationMs: totalDurationMs, language: dominantLang });
906
+ this.stateMachine.transition(AgentStates.COMPLETE);
907
+ eventBus.emit(Events.AGENT_FINAL_ANSWER, { queryId, answer: refusalMessage, durationMs: totalDurationMs });
908
+ eventBus.emit(Events.AGENT_QUERY_END, { queryId, durationMs: totalDurationMs });
909
+ this.stateMachine.reset();
910
+ await this.saveSession().catch(() => {});
911
+ return refusalMessage;
912
+ }
913
+
812
914
  let promptForLLMWithGrounding = promptForLLM;
813
915
  if (this.lastCitations && this.lastCitations.length > 0) {
814
916
  const citedRef = resolveCitationReference(promptForLLM, this.lastCitations);
@@ -821,17 +923,21 @@ ${toolsSummary}
821
923
  const userMessage = { role: 'user', content: promptForLLMWithGrounding };
822
924
  this.conversationHistory.push(userMessage);
823
925
 
824
- const memoryContext = this.memory.buildMemoryContext(promptForLLM);
926
+ const memoryContext = (this.activeRunId && typeof this.memory.addMemoryContext === 'function')
927
+ ? await this.memory.addMemoryContext({ currentQuery: promptForLLM, activeRunId: this.activeRunId })
928
+ : this.memory.buildMemoryContext(promptForLLM);
825
929
  this.conversationHistory[0] = {
826
930
  role: 'system',
827
931
  content: this.systemPrompt + memoryContext
828
932
  };
829
933
 
934
+ const turnDirectives = [];
935
+
830
936
  let langInstruction;
831
937
  if (dominantLang === 'th') {
832
- langInstruction = '[SYSTEM NOTE: User language is Thai. Reply in Thai only.]\n';
938
+ langInstruction = '[SYSTEM NOTE: User language is Thai. Reply in Thai only. Maintain Thai even when explaining errors, refusals, or reporting data not found.]\n';
833
939
  } else if (dominantLang === 'zh') {
834
- langInstruction = '[SYSTEM NOTE: User language is Chinese. Reply in Chinese only.]\n';
940
+ langInstruction = '[SYSTEM NOTE: User language is Chinese. Reply in Chinese only. Maintain Chinese even when reporting data not found.]\n';
835
941
  } else if (dominantLang === 'ja') {
836
942
  langInstruction = '[SYSTEM NOTE: User language is Japanese. Reply in Japanese only.]\n';
837
943
  } else if (dominantLang === 'ko') {
@@ -844,7 +950,59 @@ ${toolsSummary}
844
950
  if (directMentions.length > 0) {
845
951
  langInstruction += `[SYSTEM DIRECTIVE: User explicitly invoked @${directMentions.join(', @')}. You MUST prioritize calling the referenced tool(s) to fetch accurate data.]\n`;
846
952
  }
847
- this.conversationHistory.push({ role: 'system', content: langInstruction });
953
+ turnDirectives.push({ role: 'system', content: langInstruction });
954
+
955
+ if (/(เธอ|เขา|ศิลปิน|\bshe\b|\bher\b)/i.test(promptForLLM) && !/(บอต|bot|ai|คุณคือใคร|who are you)/i.test(promptForLLM)) {
956
+ turnDirectives.push({
957
+ role: 'system',
958
+ content: '[SUBJECT DISAMBIGUATION: The pronoun/subject ("เธอ", "เขา", "ศิลปิน", "She", "Her") in this query refers strictly to Ju Jingyi (鞠婧祎), NOT to the AI assistant. Query the appropriate catalog tool for Ju Jingyi\'s facts/quotes/works.]'
959
+ });
960
+ }
961
+
962
+ for (const directive of (Array.isArray(systemDirectives) ? systemDirectives : [])) {
963
+ if (directive && typeof directive === 'string') {
964
+ turnDirectives.push({ role: 'system', content: directive });
965
+ }
966
+ }
967
+
968
+ let autoResearchTriggered = false;
969
+ const autoResearchMode = String(config.agent.autoResearch || 'auto').toLowerCase();
970
+ if (autoResearchMode !== 'off') {
971
+ const complexity = scoreResearchComplexity(promptForLLM);
972
+ if (shouldAutoResearch(promptForLLM, { mode: autoResearchMode })) {
973
+ const orchestratorAvailable = this.getAllToolsSchema().some(t => t.function?.name === 'run_deep_research');
974
+ if (orchestratorAvailable) {
975
+ autoResearchTriggered = true;
976
+ turnDirectives.push({ role: 'system', content: buildAutoResearchDirective(complexity) });
977
+ }
978
+ }
979
+ }
980
+
981
+ const matchedQueryIntents = this.router.detectIntents(promptForLLM);
982
+ const queryResolution = resolveQueryEntities(promptForLLM);
983
+
984
+ if (this.conversationHistory.length > 2) {
985
+ const priorAssistantMsgs = this.conversationHistory
986
+ .filter(m => m.role === 'assistant' && typeof m.content === 'string')
987
+ .slice(-2);
988
+ const priorText = priorAssistantMsgs.map(m => m.content).join(' ');
989
+ const priorResolution = resolveQueryEntities(priorText);
990
+
991
+ if (priorResolution.hasBrand && (queryResolution.hasSong || queryResolution.hasWork || queryResolution.hasMedia)) {
992
+ turnDirectives.push({
993
+ role: 'system',
994
+ content: '[CONTEXT DISAMBIGUATION: The user has shifted focus away from earlier brand/endorsement discussions. The current query targets a music/video/drama entity. Query tools appropriate for works/songs/videos, NOT brand endorsements.]'
995
+ });
996
+ }
997
+ }
998
+
999
+ const mentionsArtist = /(ju\s?jing\s?yi|jujingyi|จวีจิ้งอี|จิ้งอี|จวี|鞠婧祎|小鞠|kikuuu?|miju|蜜橘)/i.test(promptForLLM);
1000
+ if (queryResolution.matchedEntities.length === 0 && !mentionsArtist && !autoResearchTriggered) {
1001
+ turnDirectives.push({
1002
+ role: 'system',
1003
+ content: "[SYSTEM NOTE: This query does not appear to be about Ju Jingyi or her catalog. Answer directly from your general knowledge in the user's language. Do NOT call catalog tools (profile, works, discography, brands, awards, gallery, quotes, timeline) for this query. When using web_search, search_news, or search_chinese_web, search for the specific entity or topic the user asked about — do NOT prepend 'Ju Jingyi' or '鞠婧祎' to the search query.]"
1004
+ });
1005
+ }
848
1006
 
849
1007
  const clarifyingResult = await this._handleClarifyingQuestions(
850
1008
  promptForLLM,
@@ -853,13 +1011,13 @@ ${toolsSummary}
853
1011
  callbacks
854
1012
  );
855
1013
  if (clarifyingResult) {
856
- this.conversationHistory.push({
1014
+ turnDirectives.push({
857
1015
  role: 'user',
858
1016
  content: `[SYSTEM CLARIFICATION] ${clarifyingResult.summary}\nOriginal query: ${promptForLLM}`
859
1017
  });
860
1018
  }
861
1019
 
862
- this.applySlidingWindow();
1020
+ await this.applySlidingWindow();
863
1021
 
864
1022
  const currentAssistantSessionMsg = this.session.createAssistantMessage({
865
1023
  model: this.client?.model || null,
@@ -872,12 +1030,25 @@ ${toolsSummary}
872
1030
 
873
1031
  let currentIteration = 0;
874
1032
  const toolResultCache = new Map();
1033
+ const repeatSignatureCounts = new Map();
1034
+ this.routingInterceptor.reset();
1035
+ let textProtocolTaught = false;
875
1036
  const toolResultTracker = { notFound: 0, error: 0, success: 0, total: 0, toolNames: [] };
1037
+ const recordedTools = [];
876
1038
  const activeTools = this.router.routeToolsForQuery(promptForLLM, {
877
1039
  showAll: callbacks.showAllTools || false,
878
1040
  includeMcp: true,
879
1041
  });
880
1042
 
1043
+ if (autoResearchTriggered) {
1044
+ const orchestrator = this.getAllToolsSchema().find(t => t.function?.name === 'run_deep_research');
1045
+ if (orchestrator && !activeTools.some(t => t.function?.name === 'run_deep_research')) {
1046
+ activeTools.push(orchestrator);
1047
+ }
1048
+ }
1049
+ this._activeToolSchema = activeTools;
1050
+ const activeToolNames = new Set(activeTools.map(t => t.function?.name).filter(Boolean));
1051
+
881
1052
  while (currentIteration < this.maxIterations) {
882
1053
  if (signal?.aborted) {
883
1054
  this.stateMachine.reset();
@@ -887,19 +1058,31 @@ ${toolsSummary}
887
1058
  currentIteration++;
888
1059
  onStepStart(currentIteration, this.maxIterations);
889
1060
 
1061
+ if (currentIteration > 1) {
1062
+ await this.applySlidingWindow();
1063
+ }
1064
+ this._enforceContextBudget();
1065
+
890
1066
  let assistantMessage = null;
891
1067
  let isReasoning = false;
892
1068
  let isTokenEmitting = false;
893
1069
 
894
1070
  try {
1071
+ const trackerSegment = tokenTracker.beginSegment(`iteration-${currentIteration}`);
1072
+ const chatMessages = [
1073
+ this.conversationHistory[0],
1074
+ ...turnDirectives,
1075
+ ...this.conversationHistory.slice(1)
1076
+ ];
895
1077
  assistantMessage = await this.client.chat({
896
- messages: this.conversationHistory,
1078
+ messages: chatMessages,
897
1079
  tools: activeTools,
898
1080
  temperature: 0.3,
899
- maxTokens: 8192,
1081
+ maxTokens: OUTPUT_TOKEN_BUDGET,
900
1082
  stream: true,
901
1083
  signal,
902
1084
  onThought: (thoughtChunk) => {
1085
+ tokenTracker.markToken(trackerSegment);
903
1086
  if (!isReasoning) {
904
1087
  isReasoning = true;
905
1088
  this.stateMachine.transition(AgentStates.STREAMING_THOUGHTS);
@@ -911,6 +1094,7 @@ ${toolsSummary}
911
1094
  eventBus.emit(Events.AGENT_REASONING_DELTA, { queryId, chunk: thoughtChunk });
912
1095
  },
913
1096
  onToken: (tokenChunk) => {
1097
+ tokenTracker.markToken(trackerSegment);
914
1098
  if (isReasoning) {
915
1099
  isReasoning = false;
916
1100
  this.session.finalizeReasoningPart(currentAssistantSessionMsg);
@@ -939,6 +1123,11 @@ ${toolsSummary}
939
1123
  }
940
1124
  onReasoningEnd();
941
1125
  }
1126
+
1127
+ tokenTracker.endSegment({
1128
+ promptTokens: assistantMessage.usage?.prompt_tokens,
1129
+ completionTokens: assistantMessage.usage?.completion_tokens,
1130
+ }, trackerSegment);
942
1131
  } catch (err) {
943
1132
  this.stateMachine.reset();
944
1133
  if (!isAbortError(err)) {
@@ -960,22 +1149,83 @@ ${toolsSummary}
960
1149
  }
961
1150
 
962
1151
  let toolCalls = assistantMessage.tool_calls || [];
1152
+ let malformedTextCalls = 0;
963
1153
  if (toolCalls.length === 0 && assistantMessage.content) {
964
- toolCalls = this.parseTextToolCalls(assistantMessage.content);
965
-
966
- if (toolCalls.length > 0) {
967
- let cleaned = assistantMessage.content;
1154
+ const parsedTextCalls = this._extractTextToolCalls(assistantMessage.content);
1155
+ toolCalls = parsedTextCalls.calls;
1156
+ malformedTextCalls = parsedTextCalls.malformed;
968
1157
 
969
- cleaned = cleaned.replace(/```(?:json)?\s*\{[\s\S]*?\}\s*```/g, '');
1158
+ if (toolCalls.length > 0) {
1159
+ assistantMessage.content = stripToolCallBlocks(assistantMessage.content);
970
1160
 
971
- cleaned = cleaned.replace(/Action:\s*[a-zA-Z0-9_]+\s*\nAction Input:\s*(?:\{[\s\S]*?\}|[^\n]+)/g, '');
972
- assistantMessage.content = cleaned.trim();
1161
+ assistantMessage.tool_calls = toolCalls.map(tc => ({
1162
+ id: tc.id,
1163
+ type: 'function',
1164
+ function: tc.function
1165
+ }));
973
1166
  }
974
1167
  }
975
1168
 
976
- this.conversationHistory.push(assistantMessage);
1169
+ const historyMessage = {
1170
+ role: 'assistant',
1171
+ content: assistantMessage.content || '',
1172
+ ...(assistantMessage.tool_calls ? { tool_calls: assistantMessage.tool_calls } : {})
1173
+ };
1174
+ this.conversationHistory.push(historyMessage);
977
1175
 
978
1176
  if (!toolCalls || toolCalls.length === 0) {
1177
+
1178
+ if (currentIteration === 1 && recordedTools.length === 0) {
1179
+ const content = String(assistantMessage.content || '');
1180
+ const refusalRegex = /(ไม่มี(การ)?บันทึก|ไม่มี(อยู่ใน)?ระบบ|ไม่มีข้อมูล|ไม่มีรายละเอียด|ไม่พบ|ข้อมูลทางธุรกิจ|ข้อมูลธุรกิจ|ข้อมูลเชิงพาณิชย์|ความลับทางการค้า|ความลับ|ความลับเชิงพาณิชย์|ปรัชญาการทำงาน|ตัวตนของฉัน|นอกเหนือขอบเขต|ไม่สามารถระบุ|ไม่ได้ระบุ|not found|no verified record|no record|could not find|unable to find|outside the scope|confidential|internal business|trade secret|business secret|commercial secret|proprietary|internal statistics?|operational philosophy|my own mindset|without querying|没有找到|未找到|暂无|没有记录|无法查询|商业机密|内部数据|机密)/i;
1181
+ const isRefusal = refusalRegex.test(content);
1182
+ const isSec = /(ผู้ใช้คนอื่น|หัวหน้าของเขา|รหัสผ่าน|ความลับของระบบ|password|secret|credential|token|\.env|cookies\.txt|kiku_vip_keys|system\s+directive|mcp\s+directive|bypass|unauthorized)/i.test(promptForLLM);
1183
+ if (isRefusal && !isSec) {
1184
+ this.conversationHistory.pop();
1185
+ if (currentAssistantSessionMsg) currentAssistantSessionMsg.parts = [];
1186
+ this.conversationHistory.push({
1187
+ role: 'user',
1188
+ content: '[MANDATORY RETRIEVAL RULE & VERIFICATION CONSTRAINT] You are strictly FORBIDDEN from asserting that catalog information does not exist, is confidential, or is missing without querying a tool first in this turn. Call the appropriate catalog tool (e.g. get_brand_endorsements, search_works, search_discography, get_awards, get_quotes, get_music_videos, get_gallery_photos) NOW to fetch and verify the facts before answering.'
1189
+ });
1190
+ continue;
1191
+ }
1192
+
1193
+ const explicitCatalogRegex = /(ตาม|ใน|จาก)?(แค็ตตาล็อก|catalog|ฐานข้อมูล|database|ข้อมูลทางการ|บันทึกทางการ)/i;
1194
+ const isExplicitCatalogMention = explicitCatalogRegex.test(promptForLLM);
1195
+ const hasCatalogTarget = (queryResolution?.recommendedTools?.length > 0 || queryResolution?.matchedEntities?.length > 0 || isExplicitCatalogMention) && (mentionsArtist || isExplicitCatalogMention);
1196
+
1197
+ if (!isSec && hasCatalogTarget) {
1198
+ const suggestedTools = queryResolution?.recommendedTools?.length > 0
1199
+ ? queryResolution.recommendedTools.join(', ')
1200
+ : 'search_works, search_discography, get_brand_endorsements, get_career_timeline, get_profile_and_fandom';
1201
+ this.conversationHistory.pop();
1202
+ if (currentAssistantSessionMsg) currentAssistantSessionMsg.parts = [];
1203
+ this.conversationHistory.push({
1204
+ role: 'user',
1205
+ content: `[MANDATORY CATALOG-FIRST RETRIEVAL] This request asks for verified facts from the official catalog. You must NOT provide an unverified direct answer without calling a catalog tool first. Call the appropriate tool (recommended: ${suggestedTools}) NOW to retrieve and verify the exact facts before formulating your response.`
1206
+ });
1207
+ continue;
1208
+ }
1209
+ }
1210
+
1211
+ const needProtocol = Boolean(this.client?.toolsDisabledReason) && !textProtocolTaught;
1212
+ if (currentIteration < this.maxIterations && (needProtocol || malformedTextCalls > 0)) {
1213
+ if (needProtocol) {
1214
+ textProtocolTaught = true;
1215
+ this.conversationHistory.push({
1216
+ role: 'system',
1217
+ content: buildTextToolProtocolDirective(this.getAllToolsSchema())
1218
+ });
1219
+ try {
1220
+ onNotice('Native tool calling is unavailable for this model/provider — switched to the text tool protocol.');
1221
+ } catch { }
1222
+ }
1223
+ if (malformedTextCalls > 0) {
1224
+ this.conversationHistory.push({ role: 'user', content: MALFORMED_TOOL_CALL_FEEDBACK });
1225
+ }
1226
+ continue;
1227
+ }
1228
+
979
1229
  const totalDurationMs = Date.now() - queryStartTime;
980
1230
  this.session.stats.totalDurationMs += totalDurationMs;
981
1231
 
@@ -986,6 +1236,10 @@ ${toolsSummary}
986
1236
  dominantLang === 'en' ? "Sorry, unable to process a response." :
987
1237
  "ขออภัยค่ะ ไม่สามารถประมวลผลคำตอบได้"
988
1238
  ));
1239
+ historyMessage.content = finalAnswer;
1240
+ this.conversationHistory = this.conversationHistory.filter(m =>
1241
+ !(typeof m?.content === 'string' && m.content.includes('[MANDATORY'))
1242
+ );
989
1243
  onFinalAnswer(finalAnswer, { durationMs: totalDurationMs, language: dominantLang });
990
1244
  this._extractFactsFromFinalAnswer(finalAnswer);
991
1245
 
@@ -993,29 +1247,68 @@ ${toolsSummary}
993
1247
  eventBus.emit(Events.AGENT_FINAL_ANSWER, { queryId, answer: finalAnswer, durationMs: totalDurationMs });
994
1248
  this.stateMachine.reset();
995
1249
 
1250
+ this._recordQueryMetrics(tokenTracker, {
1251
+ promptTokens: queryPromptTokens,
1252
+ completionTokens: queryCompletionTokens,
1253
+ });
1254
+
996
1255
  await this.saveSession().catch(err => console.error('Session save failed:', err));
997
1256
  return finalAnswer;
998
1257
  }
999
1258
 
1259
+ let shouldAbortLoop = false;
1000
1260
  const executionPromises = toolCalls.map(async (tc, toolCallIndex) => {
1001
1261
  if (signal?.aborted) throw createAbortError();
1002
1262
 
1003
- const toolName = tc?.function?.name || '';
1004
- const toolCallId = tc?.id || `call_${queryId}_${currentIteration}_${toolCallIndex}`;
1005
- if (this.allowedToolNames && !this.allowedToolNames.has(toolName)) {
1006
- const rejectedResult = { status: 'error', message: `Tool "${toolName}" is not permitted in this execution context.` };
1007
- onToolStart(toolName, {});
1008
- onToolResult(toolName, rejectedResult, 0);
1009
- return {
1010
- toolMessage: {
1011
- role: 'tool',
1012
- tool_call_id: toolCallId,
1013
- name: toolName,
1014
- content: JSON.stringify(rejectedResult),
1015
- },
1016
- };
1017
- }
1018
- const isBuiltin = isBuiltinTool(toolName);
1263
+
1264
+ const intercepted = this.routingInterceptor.interceptToolCall(tc, currentIteration - 1);
1265
+ if (intercepted.aborted) {
1266
+ shouldAbortLoop = true;
1267
+ return null;
1268
+ }
1269
+ if (intercepted.rerouted && intercepted.replacement) {
1270
+ tc = {
1271
+ ...tc,
1272
+ function: {
1273
+ name: intercepted.replacement.name,
1274
+ arguments: JSON.stringify(intercepted.replacement.params)
1275
+ }
1276
+ };
1277
+ }
1278
+
1279
+ const toolName = tc?.function?.name || '';
1280
+ const toolCallId = tc?.id || `call_${queryId}_${currentIteration}_${toolCallIndex}`;
1281
+ if (this.allowedToolNames && !this.allowedToolNames.has(toolName)) {
1282
+ const rejectedResult = { status: 'error', message: `Tool "${toolName}" is not permitted in this execution context.` };
1283
+ onToolStart(toolName, {});
1284
+ onToolResult(toolName, rejectedResult, 0);
1285
+ return {
1286
+ toolMessage: {
1287
+ role: 'tool',
1288
+ tool_call_id: toolCallId,
1289
+ name: toolName,
1290
+ content: JSON.stringify(rejectedResult),
1291
+ },
1292
+ };
1293
+ }
1294
+ if (!activeToolNames.has(toolName)) {
1295
+ const available = [...activeToolNames].slice(0, 20).join(', ');
1296
+ const rejectedResult = {
1297
+ status: 'error',
1298
+ message: `Tool "${toolName}" was not offered for this query, so calling it is not allowed. Available tools for this query: ${available}. Re-answer using the available tools or the evidence you already have.`
1299
+ };
1300
+ onToolStart(toolName, {});
1301
+ onToolResult(toolName, rejectedResult, 0);
1302
+ return {
1303
+ toolMessage: {
1304
+ role: 'tool',
1305
+ tool_call_id: toolCallId,
1306
+ name: toolName,
1307
+ content: JSON.stringify(rejectedResult),
1308
+ },
1309
+ };
1310
+ }
1311
+ const isBuiltin = isBuiltinTool(toolName);
1019
1312
  const isMcp = !isBuiltin && (toolName.startsWith('mcp_') || Boolean(this.mcpManager?.isMcpTool(toolName)));
1020
1313
 
1021
1314
  let prepared;
@@ -1067,6 +1360,8 @@ ${toolsSummary}
1067
1360
  const toolStartTime = Date.now();
1068
1361
  const signature = stableToolSignature(toolName, finalArgs);
1069
1362
  const cachedResult = toolResultCache.get(signature);
1363
+ const repeatCount = (repeatSignatureCounts.get(signature) || 0) + 1;
1364
+ repeatSignatureCounts.set(signature, repeatCount);
1070
1365
 
1071
1366
  let result;
1072
1367
  if (cachedResult) {
@@ -1096,6 +1391,15 @@ ${toolsSummary}
1096
1391
 
1097
1392
  const cappedResult = this._capToolResultForHistory(result);
1098
1393
 
1394
+ let instruction = result.status === 'not_found'
1395
+ ? 'CRITICAL: No matching data was found for this query in the catalog. State explicitly in the user\'s language that no verified records exist in the database. Do NOT invent, fabricate, or guess any records. Do NOT claim you searched external platforms like Bilibili, Weibo, or Google unless those specific tools were called. Under no circumstances should you generate plausible-sounding but unverified details.'
1396
+ : result.status === 'error'
1397
+ ? 'CRITICAL: The tool failed to retrieve data. Do NOT claim that the requested facts were verified. You MUST acknowledge the error and either retry or inform the user that the information could not be retrieved.'
1398
+ : 'Use ONLY the specific facts present in this result when answering. Do NOT add, infer, or extrapolate information beyond what is explicitly provided. Do NOT fabricate additional details, dates, names, or relationships not present in this result. Do not echo raw JSON data — summarize in natural language while preserving factual accuracy.';
1399
+ if (cachedResult) {
1400
+ instruction += ' NOTE: You already called this exact tool with identical arguments earlier in this query — the duplicated result above adds nothing new. Do NOT repeat this call; answer with the retrieved data now.';
1401
+ }
1402
+
1099
1403
  return {
1100
1404
  toolMessage: {
1101
1405
  role: 'tool',
@@ -1104,36 +1408,60 @@ ${toolsSummary}
1104
1408
  content: JSON.stringify({
1105
1409
  authoritative: true,
1106
1410
  result: cappedResult,
1107
- instruction: result.status === 'not_found'
1108
- ? 'CRITICAL: No matching data was found for this query. Do NOT invent, fabricate, or guess any records. You MUST either (a) state explicitly that no verified information exists, or (b) immediately call web_search to look up live external information. Under no circumstances should you generate plausible-sounding but unverified details.'
1109
- : result.status === 'error'
1110
- ? 'CRITICAL: The tool failed to retrieve data. Do NOT claim that the requested facts were verified. You MUST acknowledge the error and either retry or inform the user that the information could not be retrieved.'
1111
- : 'Use ONLY the specific facts present in this result when answering. Do NOT add, infer, or extrapolate information beyond what is explicitly provided. Do NOT fabricate additional details, dates, names, or relationships not present in this result. Do not echo raw JSON data — summarize in natural language while preserving factual accuracy.'
1411
+ instruction
1112
1412
  })
1113
- }
1413
+ },
1414
+ toolName,
1415
+ result
1114
1416
  };
1115
1417
  });
1116
1418
 
1117
1419
  const toolResults = await Promise.all(executionPromises);
1420
+
1118
1421
  for (const item of toolResults) {
1119
1422
  if (item?.toolMessage) {
1120
1423
  this.conversationHistory.push(item.toolMessage);
1121
1424
  }
1425
+ if (item?.toolName) {
1426
+ recordedTools.push({ name: item.toolName, result: item.result });
1427
+ }
1428
+ }
1429
+
1430
+
1431
+ if (shouldAbortLoop) {
1432
+ try {
1433
+ onNotice('Duplicate tool call signature detected by RoutingInterceptor — moving directly to final answer synthesis.');
1434
+ } catch { }
1435
+ break;
1436
+ }
1437
+
1438
+ if (currentIteration < this.maxIterations) {
1439
+ let stuck = false;
1440
+ for (const count of repeatSignatureCounts.values()) {
1441
+ if (count >= REPEAT_LOOP_THRESHOLD) { stuck = true; break; }
1442
+ }
1443
+ if (stuck) {
1444
+ try {
1445
+ onNotice('Repeated identical tool calls detected — moving to the final answer.');
1446
+ } catch { }
1447
+ break;
1448
+ }
1122
1449
  }
1123
1450
  }
1124
1451
 
1125
- const selfCritiqueDirective = await this._performSelfCritique(
1452
+ const selfCritiqueDirective = await this._performSelfCritique(
1126
1453
  toolResultTracker,
1127
1454
  dominantLang,
1128
1455
  promptForLLM
1129
1456
  );
1130
1457
 
1131
1458
  const langPrompt = dominantLang === 'zh'
1132
- ? "请根据上述工具查询结果,为粉丝提供详细、全面且深入的解答。条理清晰地阐述作品背景、角色特质、音乐成就与演艺里程碑,包含具体的 YouTube 真实链接(如 Direct Watch: https://www.youtube.com/watch?v=... 或 Search: https://www.youtube.com/results?search_query=...)及完整事实数据。\n\n【反幻觉守则 — 必须遵守】只陈述工具结果中明确包含的事实。禁止编造、推测或补充工具结果中不存在的剧名、歌曲名、角色名、品牌名、日期或任何细节。如果工具结果不足以完整回答,请明确说明哪些部分未经验证,而不是凭空补全。"
1459
+ ? "请根据上述工具查询结果回答,回答深度须与问题相称:单一事实类问题(日期、名字、数字、是/否)用 1-4 句话直接简洁作答,不加标题或表格;仅当问题是比较、排序、列表或深度分析时,才使用完整的 Markdown 结构(标题、要点、表格)并附上具体的 YouTube 真实链接(如 Direct Watch: https://www.youtube.com/watch?v=... 或 Search: https://www.youtube.com/results?search_query=...)。不要用无关段落填充简短的事实性回答,但也绝不能遗漏用户明确要求的任何条目。\n\n【反幻觉守则 — 必须遵守】只陈述工具结果中明确包含的事实。禁止编造、推测或补充工具结果中不存在的剧名、歌曲名、角色名、品牌名、日期或任何细节。如果工具结果不足以完整回答,请明确说明哪些部分未经验证,而不是凭空补全。"
1133
1460
  : dominantLang === 'en'
1134
- ? "Based on the tool results above, provide a comprehensive, richly detailed response for fans. Explain background storylines, character traits, musical highlights, and career milestones with well-organized Markdown headers, bullet points, and verified YouTube links (https://www.youtube.com/results?search_query=...).\n\n【Anti-Hallucination Mandate — MANDATORY】Only state facts explicitly present in the tool results above. Do NOT fabricate, infer, or supplement drama titles, song names, character roles, brand names, dates, or any details not found in the tool results. If the tool results are insufficient to fully answer, explicitly note which parts are unverified rather than inventing plausible-sounding details."
1135
- : "สรุปคำตอบจากผลลัพธ์เครื่องมือข้างต้น โดยให้ข้อมูลแฟนคลับอย่างละเอียด ลึกซึ้ง ครบถ้วนและชัดเจน จัดโครงสร้างด้วยหัวข้อ Markdown, ตาราง และ bullet points ให้สวยงาม พร้อมแสดงลิงก์ YouTube จริง (Direct Watch Link และ https://www.youtube.com/results?search_query=...) พร้อมเกร็ดประวัติและผลงานอย่างเต็มที่\n\n【กฎป้องกันการแต่งข้อมูล — บังคับใช้】ระบุเฉพาะข้อเท็จจริงที่มีอยู่จริงในผลลัพธ์เครื่องมือเท่านั้น ห้ามสร้างชื่อละคร เพลง ตัวละคร แบรนด์ วันที่ หรือรายละเอียดใดๆ ที่ไม่มีอยู่ในผลลัพธ์เครื่องมือ หากข้อมูลไม่เพียงพอสำหรับคำตอบที่สมบูรณ์ ให้ระบุชัดเจนว่าส่วนใดยังไม่ได้รับการยืนยัน แทนการเติมข้อมูลที่ฟังดูน่าเชื่อแต่ไม่มีหลักฐาน";
1461
+ ? "Based on the tool results above, provide a response whose depth matches the question: for simple single-fact questions (a date, a name, a number, yes/no) answer concisely and directly in 1-4 sentences without headers or tables; only for comparison, ranking, list, or deep-dive questions use rich detail with well-organized Markdown headers, bullet points, and verified YouTube links (https://www.youtube.com/results?search_query=...). Never pad short factual answers with filler sections, and never omit any item the user explicitly requested.\n\n【Anti-Hallucination Mandate — MANDATORY】Only state facts explicitly present in the tool results above. Do NOT fabricate, infer, or supplement drama titles, song names, character roles, brand names, dates, or any details not found in the tool results. If the tool results are insufficient to fully answer, explicitly note which parts are unverified rather than inventing plausible-sounding details."
1462
+ : "สรุปคำตอบจากผลลัพธ์เครื่องมือข้างต้น โดยให้ความลึกของคำตอบสมดุลกับคำถาม: คำถามข้อเท็จจริงเดี่ยว (วันที่ ชื่อ ตัวเลข ใช่/ไม่ใช่) ตอบสั้น ตรงประเด็น 1-4 ประโยค โดยไม่ต้องมีหัวข้อหรือตาราง ส่วนคำถามเปรียบเทียบ จัดอันดับ จัดรายการ หรือวิเคราะห์เชิงลึก จึงจัดโครงสร้างด้วยหัวข้อ Markdown, ตาราง และ bullet points พร้อมแสดงลิงก์ YouTube จริง (Direct Watch Link และ https://www.youtube.com/results?search_query=...) อย่างครบถ้วน ห้ามเติมเนื้อหาฟุ่มเฟือยในคำตอบสั้น แต่ห้ามลดทอนรายการที่ผู้ใช้ระบุขอมาทุกข้อ\n\n【กฎป้องกันการแต่งข้อมูล — บังคับใช้】ระบุเฉพาะข้อเท็จจริงที่มีอยู่จริงในผลลัพธ์เครื่องมือเท่านั้น ห้ามสร้างชื่อละคร เพลง ตัวละคร แบรนด์ วันที่ หรือรายละเอียดใดๆ ที่ไม่มีอยู่ในผลลัพธ์เครื่องมือ หากข้อมูลไม่เพียงพอสำหรับคำตอบที่สมบูรณ์ ให้ระบุชัดเจนว่าส่วนใดยังไม่ได้รับการยืนยัน แทนการเติมข้อมูลที่ฟังดูน่าเชื่อแต่ไม่มีหลักฐาน";
1136
1463
 
1464
+ const synthHistoryBase = this.conversationHistory.length;
1137
1465
  this.conversationHistory.push({ role: 'user', content: langPrompt });
1138
1466
  this.conversationHistory.push({ role: 'system', content: selfCritiqueDirective });
1139
1467
 
@@ -1143,13 +1471,19 @@ ${toolsSummary}
1143
1471
 
1144
1472
  let isReasoning = false;
1145
1473
  let synthTokenStarted = false;
1474
+ const synthSegment = tokenTracker.beginSegment('synthesis');
1146
1475
  const finalRes = await this.client.chat({
1147
- messages: this.conversationHistory,
1476
+ messages: [
1477
+ this.conversationHistory[0],
1478
+ ...turnDirectives,
1479
+ ...this.conversationHistory.slice(1)
1480
+ ],
1148
1481
  temperature: 0.3,
1149
- maxTokens: 8192,
1482
+ maxTokens: OUTPUT_TOKEN_BUDGET,
1150
1483
  stream: true,
1151
1484
  signal,
1152
1485
  onThought: (thoughtChunk) => {
1486
+ tokenTracker.markToken(synthSegment);
1153
1487
  if (!isReasoning) {
1154
1488
  isReasoning = true;
1155
1489
  onReasoningStart();
@@ -1158,6 +1492,7 @@ ${toolsSummary}
1158
1492
  onReasoningDelta(thoughtChunk);
1159
1493
  },
1160
1494
  onToken: (tokenChunk) => {
1495
+ tokenTracker.markToken(synthSegment);
1161
1496
  if (isReasoning) {
1162
1497
  isReasoning = false;
1163
1498
  this.session.finalizeReasoningPart(currentAssistantSessionMsg);
@@ -1172,6 +1507,14 @@ ${toolsSummary}
1172
1507
  }
1173
1508
  });
1174
1509
 
1510
+
1511
+ this.conversationHistory.length = Math.min(this.conversationHistory.length, synthHistoryBase);
1512
+
1513
+ tokenTracker.endSegment({
1514
+ promptTokens: finalRes?.usage?.prompt_tokens,
1515
+ completionTokens: finalRes?.usage?.completion_tokens,
1516
+ }, synthSegment);
1517
+
1175
1518
  if (finalRes?.usage) {
1176
1519
  queryPromptTokens += Number(finalRes.usage.prompt_tokens || 0);
1177
1520
  queryCompletionTokens += Number(finalRes.usage.completion_tokens || 0);
@@ -1195,6 +1538,11 @@ ${toolsSummary}
1195
1538
  this.session.recordQueryTokens(queryTokens, queryCostUsd);
1196
1539
  this.lastTelemetry = { tokens: queryTokens, costUsd: queryCostUsd };
1197
1540
 
1541
+ this._recordQueryMetrics(tokenTracker, {
1542
+ promptTokens: queryPromptTokens,
1543
+ completionTokens: queryCompletionTokens,
1544
+ });
1545
+
1198
1546
  const totalDurationMs = Date.now() - queryStartTime;
1199
1547
  this.session.stats.totalDurationMs += totalDurationMs;
1200
1548
 
@@ -1204,6 +1552,10 @@ ${toolsSummary}
1204
1552
  dominantLang === 'ko' ? "검색이 완료되었습니다" :
1205
1553
  dominantLang === 'en' ? "Search complete." : "เสร็จสิ้นการค้นหาข้อมูล";
1206
1554
  const output = sanitizeResponse(finalRes?.content || defaultDone);
1555
+ this.conversationHistory.push({ role: 'assistant', content: output });
1556
+ this.conversationHistory = this.conversationHistory.filter(m =>
1557
+ !(typeof m?.content === 'string' && m.content.includes('[MANDATORY'))
1558
+ );
1207
1559
  const citations = extractCitations(output, recordedTools);
1208
1560
  this.lastCitations = citations;
1209
1561
  if (currentAssistantSessionMsg) {
@@ -1245,6 +1597,22 @@ ${toolsSummary}
1245
1597
  return this.ask(userPrompt, callbacks);
1246
1598
  }
1247
1599
 
1600
+ /** Fold the query's TokenTracker timings into lastQueryMetrics / history. */
1601
+ _recordQueryMetrics(tokenTracker, { promptTokens, completionTokens } = {}) {
1602
+ try {
1603
+ if (!tokenTracker) return null;
1604
+ const payload = tokenTracker.finish({ promptTokens, completionTokens });
1605
+ if (payload) {
1606
+ this.lastQueryMetrics = payload;
1607
+ this.queryMetricsHistory.push(payload);
1608
+ if (this.queryMetricsHistory.length > 50) this.queryMetricsHistory.shift();
1609
+ }
1610
+ return payload;
1611
+ } catch {
1612
+ return null;
1613
+ }
1614
+ }
1615
+
1248
1616
  _computeConfidenceScore(toolResultTracker) {
1249
1617
  if (toolResultTracker.total === 0) return 0;
1250
1618
 
@@ -1312,16 +1680,17 @@ ${toolsSummary}
1312
1680
  _capToolResultForHistory(result) {
1313
1681
  if (!result || typeof result !== 'object') return result;
1314
1682
 
1315
- const MAX_RESULT_BYTES = 16384;
1316
- const serialized = JSON.stringify(result);
1317
- if (serialized.length <= MAX_RESULT_BYTES) return result;
1683
+ const compacted = compactToolResultForContext(result, { maxTokens: 800 });
1318
1684
 
1685
+ const MAX_RESULT_BYTES = 16384;
1686
+ const serialized = JSON.stringify(compacted);
1687
+ if (serialized.length <= MAX_RESULT_BYTES) return compacted;
1319
1688
 
1320
1689
  const truncate = (obj) => {
1321
1690
  if (Array.isArray(obj)) {
1322
- if (obj.length > 8) {
1323
- const kept = obj.slice(0, 8).map(truncate);
1324
- kept.push({ _truncated: true, totalItems: obj.length, showing: 8 });
1691
+ if (obj.length > 5) {
1692
+ const kept = obj.slice(0, 5).map(truncate);
1693
+ kept.push({ _truncated: true, totalItems: obj.length, showing: 5 });
1325
1694
  return kept;
1326
1695
  }
1327
1696
  return obj.map(truncate);
@@ -1333,14 +1702,13 @@ ${toolsSummary}
1333
1702
  }
1334
1703
  return out;
1335
1704
  }
1336
-
1337
- if (typeof obj === 'string' && obj.length > 500) {
1338
- return `${obj.slice(0, 500)}…[truncated]`;
1705
+ if (typeof obj === 'string' && obj.length > 300) {
1706
+ return `${obj.slice(0, 300)}…[truncated]`;
1339
1707
  }
1340
1708
  return obj;
1341
1709
  };
1342
1710
 
1343
- const capped = truncate(result);
1711
+ const capped = truncate(compacted);
1344
1712
  capped._cappedForHistory = true;
1345
1713
  return capped;
1346
1714
  }