kiku-agent 1.3.0 → 1.3.2

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 (155) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -7
  3. package/server/agent/agentCore.js +2 -2
  4. package/server/agent/catalogEntityResolver.js +8 -0
  5. package/server/agent/cli.js +4 -0
  6. package/server/agent/eval/AGENTS.md +228 -0
  7. package/server/agent/eval/analyze.py +684 -0
  8. package/server/agent/eval/gates.json +15 -0
  9. package/server/agent/eval/modules/analysis/compare.js +198 -0
  10. package/server/agent/eval/modules/analysis/gates.js +72 -0
  11. package/server/agent/eval/modules/analysis/judge.js +70 -0
  12. package/server/agent/eval/modules/core/cli.js +453 -0
  13. package/server/agent/eval/modules/core/config.js +60 -0
  14. package/server/agent/eval/modules/core/store.js +236 -0
  15. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  16. package/server/agent/eval/modules/evaluation/runner.js +298 -0
  17. package/server/agent/eval/modules/evaluation/scoring.js +351 -0
  18. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  19. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  20. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  21. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  22. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  23. package/server/agent/eval/modules/ui/evalUi.js +4796 -0
  24. package/server/agent/eval/modules/utils/smokes.js +119 -0
  25. package/server/agent/ink/App.jsx +348 -0
  26. package/server/agent/ink/Panel.jsx +54 -0
  27. package/server/agent/ink/PromptInput.jsx +321 -0
  28. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  29. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  30. package/server/agent/ink/ToolResult.jsx +133 -0
  31. package/server/agent/ink/main.jsx +195 -0
  32. package/server/agent/ink/useEventBus.js +218 -0
  33. package/server/agent/memory/contextAllocator.js +214 -0
  34. package/server/agent/memory/hierarchicalStore.js +523 -0
  35. package/server/agent/memory/reflectionWorker.js +110 -0
  36. package/server/agent/prompts.js +1 -1
  37. package/server/agent/telemetry/hardwareTiers.js +332 -0
  38. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  39. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  40. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  41. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  42. package/server/agent/tests/agentFollowUps.test.js +127 -0
  43. package/server/agent/tests/apiRouter.test.js +108 -0
  44. package/server/agent/tests/architectureFixes.test.js +81 -0
  45. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  46. package/server/agent/tests/autoResearch.test.js +64 -0
  47. package/server/agent/tests/bilibiliEmbed.test.js +251 -0
  48. package/server/agent/tests/catalogSync.test.js +130 -0
  49. package/server/agent/tests/citations.test.js +180 -0
  50. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  51. package/server/agent/tests/clarifyingQuestions.test.js +594 -0
  52. package/server/agent/tests/cliAutomation.test.js +121 -0
  53. package/server/agent/tests/cloudProviders.test.js +234 -0
  54. package/server/agent/tests/commandInput.test.js +24 -0
  55. package/server/agent/tests/commands.test.js +417 -0
  56. package/server/agent/tests/contextIntelligence.test.js +162 -0
  57. package/server/agent/tests/conversationSafety.test.js +53 -0
  58. package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
  59. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  60. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  61. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  62. package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
  63. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  64. package/server/agent/tests/envBootstrap.test.js +87 -0
  65. package/server/agent/tests/envCommand.test.js +215 -0
  66. package/server/agent/tests/errorHandling.test.js +80 -0
  67. package/server/agent/tests/errors.test.js +13 -0
  68. package/server/agent/tests/evalAnalyze.test.js +54 -0
  69. package/server/agent/tests/evalCommand.test.js +54 -0
  70. package/server/agent/tests/evalCorpus.test.js +126 -0
  71. package/server/agent/tests/evalHarness.test.js +62 -0
  72. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  73. package/server/agent/tests/evalMetrics.test.js +312 -0
  74. package/server/agent/tests/evalStore.test.js +190 -0
  75. package/server/agent/tests/evalStudio.test.js +179 -0
  76. package/server/agent/tests/feedbackLearning.test.js +186 -0
  77. package/server/agent/tests/firstRun.test.js +44 -0
  78. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  79. package/server/agent/tests/hardwareTiers.test.js +206 -0
  80. package/server/agent/tests/hybridWebSearch.test.js +192 -0
  81. package/server/agent/tests/judgeService.test.js +251 -0
  82. package/server/agent/tests/lanDiscovery.test.js +319 -0
  83. package/server/agent/tests/mcpClient.test.js +201 -0
  84. package/server/agent/tests/modelManager.test.js +109 -0
  85. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  86. package/server/agent/tests/newCommands.test.js +347 -0
  87. package/server/agent/tests/newCommandsTui.test.js +141 -0
  88. package/server/agent/tests/newTools.test.js +113 -0
  89. package/server/agent/tests/newsIntelligence.test.js +178 -0
  90. package/server/agent/tests/nextGenArchitecture.test.js +99 -0
  91. package/server/agent/tests/offlineInput.test.js +217 -0
  92. package/server/agent/tests/packagingGuard.test.js +57 -0
  93. package/server/agent/tests/presentation.test.js +61 -0
  94. package/server/agent/tests/providerPersistence.test.js +39 -0
  95. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  96. package/server/agent/tests/queryDetails.test.js +126 -0
  97. package/server/agent/tests/realWorldValidation.test.js +391 -0
  98. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  99. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  100. package/server/agent/tests/sanitizer.test.js +37 -0
  101. package/server/agent/tests/scenariosData.js +378 -0
  102. package/server/agent/tests/schemaRouting.test.js +54 -0
  103. package/server/agent/tests/securityAuditRemediation.test.js +214 -0
  104. package/server/agent/tests/securityHardening.test.js +146 -0
  105. package/server/agent/tests/securitySanitizer.test.js +38 -0
  106. package/server/agent/tests/sessionAndCopy.test.js +232 -0
  107. package/server/agent/tests/sidebarControls.test.js +101 -0
  108. package/server/agent/tests/statefulMemory.test.js +270 -0
  109. package/server/agent/tests/subagent.test.js +216 -0
  110. package/server/agent/tests/subagentProgress.test.js +109 -0
  111. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  112. package/server/agent/tests/suggestions.test.js +199 -0
  113. package/server/agent/tests/suggestionsTui.test.js +251 -0
  114. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  115. package/server/agent/tests/terminalImage.test.js +80 -0
  116. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  117. package/server/agent/tests/terminalText.test.js +133 -0
  118. package/server/agent/tests/textProtocolLoop.test.js +126 -0
  119. package/server/agent/tests/textToolParsing.test.js +139 -0
  120. package/server/agent/tests/theme.test.js +26 -0
  121. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  122. package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
  123. package/server/agent/tests/tools.test.js +259 -0
  124. package/server/agent/tests/unifiedEndpoints.test.js +152 -0
  125. package/server/agent/tests/v110Features.test.js +262 -0
  126. package/server/agent/tests/versionBump.test.js +69 -0
  127. package/server/agent/tests/vramEstimator.test.js +520 -0
  128. package/server/agent/tests/webChatInspection.test.js +300 -0
  129. package/server/agent/tests/webChatMemory.test.js +112 -0
  130. package/server/agent/tests/webChatSecurity.test.js +169 -0
  131. package/server/agent/tests/webExtract.test.js +261 -0
  132. package/server/agent/tests/webSources.test.js +247 -0
  133. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  134. package/server/agent/websearch/adapters/jina.js +77 -0
  135. package/server/agent/websearch/adapters/searxng.js +57 -0
  136. package/server/agent/websearch/core/canonical.js +89 -0
  137. package/server/agent/websearch/core/fusion.js +81 -0
  138. package/server/agent/websearch/core/types.js +29 -0
  139. package/server/agent/websearch/extract.js +605 -0
  140. package/server/agent/websearch/index.js +43 -0
  141. package/server/agent/websearch/orchestrator.js +266 -0
  142. package/server/agent/websearch/resilience/breaker.js +97 -0
  143. package/server/agent/websearch/resilience/cache.js +59 -0
  144. package/server/agent/websearch/resilience/limiter.js +93 -0
  145. package/server/agent/websearch/security/filter.js +36 -0
  146. package/server/agent/websearch/security/ssrf.js +289 -0
  147. package/server/agent/websearch/telemetry.js +71 -0
  148. package/server/apiRouter.js +7 -11
  149. package/server/eval/judgeRubrics.js +110 -0
  150. package/server/eval/judgeService.js +290 -0
  151. package/server/eval/runBenchmark.js +182 -0
  152. package/server/mcp/examples/librechat.example.yaml +16 -0
  153. package/server/mcp/tests/connection.test.js +156 -0
  154. package/server/routes/agent.js +55 -0
  155. package/server/supabase_schema.sql +63 -0
@@ -0,0 +1,135 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { executeTool } from '../tools.js';
5
+ import { compactToolResultForContext } from '../contextCompactor.js';
6
+ import { scoreCoverage } from '../eval/modules/evaluation/scoring.js';
7
+ import { dynamicToolRouter } from '../toolRouter.js';
8
+ import { buildCatalogIndex, CATEGORY_ALIASES, resolveQueryEntities } from '../catalogEntityResolver.js';
9
+
10
+ test('get_brand_endorsements resolves French high jewelry and commercial aliases', async () => {
11
+ const fredRes = await executeTool('get_brand_endorsements', { query: 'French high jewelry' }, { noCache: true });
12
+ assert.equal(fredRes.status, 'success');
13
+ assert.ok(fredRes.count > 0);
14
+ assert.ok(fredRes.brands.some(b => b.brand.toLowerCase().includes('fred')));
15
+
16
+ const tutuRes = await executeTool('get_brand_endorsements', { query: 'TuTuCare' }, { noCache: true });
17
+ assert.equal(tutuRes.status, 'success');
18
+ assert.ok(tutuRes.count > 0);
19
+ assert.ok(tutuRes.brands.some(b => b.brand.toLowerCase().includes('tutu') || b.brand.includes('凸凸棉')));
20
+
21
+ const lafangRes = await executeTool('get_brand_endorsements', { query: 'Lafang' }, { noCache: true });
22
+ assert.equal(lafangRes.status, 'success');
23
+ assert.ok(lafangRes.count > 0);
24
+ assert.ok(lafangRes.brands.some(b => b.brand.toLowerCase().includes('lafang') || b.brand.includes('拉芳')));
25
+ });
26
+
27
+ test('search_discography and get_music_videos resolve 0.2s and Dewdrops without numeric confusion', async () => {
28
+ const zeroTwoRes = await executeTool('search_discography', { query: '0.2s' }, { noCache: true });
29
+ assert.equal(zeroTwoRes.status, 'success');
30
+ assert.ok(zeroTwoRes.count > 0);
31
+ assert.ok(zeroTwoRes.songs.some(s => s.titleEn === '0.2s' || s.titleCn === '0.2s' || s.title?.includes('0.2s')));
32
+
33
+ const dewdropsRes = await executeTool('search_discography', { query: 'Dewdrops' }, { noCache: true });
34
+ assert.equal(dewdropsRes.status, 'success');
35
+ assert.ok(dewdropsRes.count > 0);
36
+ assert.ok(dewdropsRes.songs.some(s => s.id?.includes('dewdrop') || s.titleEn?.toLowerCase().includes('dew') || s.title?.includes('露珠')));
37
+
38
+ const dewdropsMv = await executeTool('get_music_videos', { query: 'Dewdrops' }, { noCache: true });
39
+ assert.equal(dewdropsMv.status, 'success');
40
+ assert.ok(dewdropsMv.count > 0);
41
+ assert.ok(dewdropsMv.videos.some(v => v.youtubeId === 'D-vEPdasbLo' || v.titleCn?.includes('露珠')));
42
+ });
43
+
44
+ test('compactToolResultForContext projects essential keys and eliminates decorative URLs', () => {
45
+ const brandResult = {
46
+ status: 'success',
47
+ count: 1,
48
+ brands: [
49
+ {
50
+ brand: 'FRED Paris (斐登珠宝)',
51
+ brandNameEn: 'FRED Paris',
52
+ brandNameCn: '斐登珠宝',
53
+ year: 2025,
54
+ title: 'China Brand Ambassador',
55
+ category: 'French High Jewelry',
56
+ isGlobal: false,
57
+ highlight: 'Force 10 High Jewelry editorial for PORT Magazine',
58
+ tier: 'LVMH High Jewelry',
59
+ youtubeCommercialSearch: 'https://youtube.com/results?search_query=FRED',
60
+ bilibiliSearchUrl: 'https://search.bilibili.com/all?keyword=FRED'
61
+ }
62
+ ]
63
+ };
64
+
65
+ const compacted = compactToolResultForContext(brandResult, { maxTokens: 800 });
66
+ assert.equal(compacted.brands.length, 1);
67
+ const b = compacted.brands[0];
68
+ assert.equal(b.brandNameEn, 'FRED Paris');
69
+ assert.equal(b.year, 2025);
70
+ assert.equal(b.youtubeCommercialSearch, undefined);
71
+ assert.equal(b.bilibiliSearchUrl, undefined);
72
+ });
73
+
74
+ test('scoreCoverage normalizes Buddhist Era years and minute-second durations', () => {
75
+ const caseBe = {
76
+ groups: [
77
+ ['2017', 'ค.ศ. 2017']
78
+ ]
79
+ };
80
+ const answerBe = 'จวีจิ้งอีจัดงานคอนเสิร์ตเดี่ยวครั้งสำคัญใน พ.ศ. 2560 อย่างยิ่งใหญ่';
81
+ const scoreBeResult = scoreCoverage(caseBe, answerBe);
82
+ assert.equal(scoreBeResult.allMatched, true, 'พ.ศ. 2560 should cover 2017');
83
+
84
+ const caseDuration = {
85
+ groups: [
86
+ ['5:01', '05:01']
87
+ ]
88
+ };
89
+ const answerDuration = 'เพลง Still Searching (仍寻) มีความยาว 5 minutes and 1 second พอดี';
90
+ const scoreDurationResult = scoreCoverage(caseDuration, answerDuration);
91
+ assert.equal(scoreDurationResult.allMatched, true, '5 minutes and 1 second should cover 5:01');
92
+ });
93
+
94
+ test('tool router routes timeline era queries to get_career_timeline', () => {
95
+ const tools = dynamicToolRouter.routeToolsForQuery('Ju Jingyi career journey and variety show milestones in 2015-2016');
96
+ const toolNames = tools.map(t => t.function?.name || t.name);
97
+ assert.ok(toolNames.includes('get_career_timeline'), 'should route to get_career_timeline');
98
+ });
99
+
100
+ test('refusal regex intercepts premature commercial secret and mindset refusals', () => {
101
+ 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;
102
+
103
+ assert.ok(refusalRegex.test('ข้อมูลยอดขายของ TuTuCare เป็นความลับเชิงพาณิชย์'));
104
+ assert.ok(refusalRegex.test('This is confidential commercial secret information.'));
105
+ assert.ok(refusalRegex.test('This is internal business statistics not publicly shared.'));
106
+ assert.ok(refusalRegex.test('I do not have access to internal trade secrets.'));
107
+ assert.ok(refusalRegex.test('นี่เป็นปรัชญาการทำงานและตัวตนของฉันเอง'));
108
+ assert.ok(refusalRegex.test('This is outside the scope of public records.'));
109
+ });
110
+
111
+ test('resolveQueryEntities detects timeline and brand intents with recommendedTools', () => {
112
+ const timelineQuery = resolveQueryEntities('ในปี 2018 ละครเรื่องอะไรที่จวีจิ้งอีแสดงร่วมกับ ไมค์ พิรัชต์');
113
+ assert.equal(timelineQuery.isTimelineIntent, true);
114
+ assert.ok(timelineQuery.recommendedTools.includes('get_career_timeline'));
115
+
116
+ const brandQuery = resolveQueryEntities('ยอดขายกล่องของขวัญ TuTuCare ของจวีจิ้งอีเป็นอย่างไร');
117
+ assert.equal(brandQuery.isBrandIntent, true);
118
+ assert.ok(brandQuery.recommendedTools.includes('get_brand_endorsements'));
119
+ });
120
+
121
+ test('compactToolResultForContext preserves salesHighlights in brands data', () => {
122
+ const brandPayload = {
123
+ brands: [
124
+ { id: 'tutucare', brand: 'TuTuCare', brandNameEn: 'TuTuCare', year: 2026, title: 'Global Ambassador' }
125
+ ],
126
+ salesHighlights: [
127
+ { brand: 'TuTuCare (凸凸棉)', brandId: 'tutucare', stat: '20,000 ชุด หมดใน 1 วินาที!', revenue: 'GMV เกิน 10.3 ล้านหยวนใน 1 ชั่วโมง', detail: 'กล่องของขวัญลิมิเต็ด' }
128
+ ]
129
+ };
130
+
131
+ const compacted = compactToolResultForContext(brandPayload);
132
+ assert.ok(compacted.salesHighlights, 'salesHighlights should be preserved');
133
+ assert.equal(compacted.salesHighlights.length, 1);
134
+ assert.equal(compacted.salesHighlights[0].stat, '20,000 ชุด หมดใน 1 วินาที!');
135
+ });
@@ -0,0 +1,73 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { executeTool } from '../tools.js';
5
+ import { resolveConversationContext } from '../../web-chat/core/conversationContext.js';
6
+ import { executeDirectCatalogKnowledge } from '../../web-chat/core/directCatalogEngine.js';
7
+ import { assessDramaAnswerQuality } from '../../web-chat/core/dramaKnowledge.js';
8
+
9
+ test('2024-versus-past analysis compares concrete role demands without unsupported rankings', async () => {
10
+ const query = 'ดำเนินการต่อ: อยากให้วิเคราะห์เปรียบเทียบบทบาทของเธอในซีรีส์ยุคใหม่ (2024) กับผลงานไอดอลในอดีต ว่ามีจุดเปลี่ยนด้านการแสดงอย่างไรบ้างคะ?';
11
+ const result = await executeDirectCatalogKnowledge(query);
12
+
13
+ assert.match(result.answer, /วิวัฒนาการต่อเนื่อง/);
14
+ assert.match(result.answer, /ตัวตนหยางไฉ่เวย.*รูปลักษณ์.*ซ่างกวนจื่อ/s);
15
+ assert.match(result.answer, /Sword and Fairy 4/);
16
+ assert.match(result.answer, /Novoland: The Castle in the Sky/);
17
+ assert.doesNotMatch(result.answer, /★\s*9\.|ระดับแนวหน้า|ไม่มีใครเทียบได้/);
18
+ assert.doesNotMatch(result.answer, /ประวัติและข้อมูลส่วนตัว/);
19
+ assert.ok(result.tools.every(tool => tool.name === 'get_work_detail'));
20
+ });
21
+
22
+ test('White Snake story keeps Bai Suzhen and Xu Xian species and genders correct', async () => {
23
+ const result = await executeDirectCatalogKnowledge('เล่าเรื่องราวและเกร็ดของ ตำนานนางพญางูขาว (The Legend of White Snake) ให้ฟังหน่อย');
24
+
25
+ assert.match(result.answer, /ไป๋ซู่เจินเป็นวิญญาณ\/ปีศาจงูขาว/);
26
+ assert.match(result.answer, /สวี่เซียนเป็นมนุษย์ชาย/);
27
+ assert.match(result.answer, /iQIYI \/ WeTV/);
28
+ assert.doesNotMatch(result.answer, /มนุษย์สาวผู้มีความงดงาม.*พญางูขาว/s);
29
+ assert.doesNotMatch(result.answer, /ไม่มีใครเทียบได้|ระดับแนวหน้า/);
30
+ assert.equal(result.tools[0]?.name, 'get_work_detail');
31
+ });
32
+
33
+ test('explicit long continuation keeps the previous topic and forbids repetition', () => {
34
+ const query = 'ดำเนินการต่อ: อยากให้วิเคราะห์เปรียบเทียบบทบาทของเธอในซีรีส์ยุคใหม่ปี 2024 กับผลงานไอดอลในอดีต โดยเน้นเฉพาะสิ่งที่ต่างจากคำตอบก่อนหน้า';
35
+ const context = resolveConversationContext(query, [
36
+ { role: 'user', text: 'ช่วยวิเคราะห์ซีรีส์ปี 2024 กับอดีตของจวีจิ้งอี' },
37
+ { role: 'assistant', text: 'คำตอบก่อนหน้ากล่าวถึง In Blossom และ The Legend of White Snake' }
38
+ ]);
39
+
40
+ assert.equal(context.contextual, true);
41
+ assert.match(context.resolvedQuery, /do not copy or restate/i);
42
+ });
43
+
44
+ test('search_works exposes synopsis and highlights needed for grounded synthesis', async () => {
45
+ const result = await executeTool('search_works', { query: 'The Legend of White Snake' });
46
+ const work = result.works[0];
47
+
48
+ assert.match(work.synopsis, /ปีศาจงูขาว/);
49
+ assert.ok(Array.isArray(work.highlights));
50
+ assert.ok(work.highlights.length > 0);
51
+ });
52
+
53
+ test('quality assessment rejects the observed hallucinated White Snake answer', () => {
54
+ const answer = 'ตัวละครเอกเป็นมนุษย์สาวผู้มีความงดงาม และเธอได้พบกับสิ่งมีชีวิตลึกลับ ซึ่งในตำนานคือพญางูขาว';
55
+ const assessment = assessDramaAnswerQuality('เล่าเรื่องราวและเกร็ดของ The Legend of White Snake', answer);
56
+
57
+ assert.equal(assessment.applicable, true);
58
+ assert.equal(assessment.valid, false);
59
+ assert.ok(assessment.issues.includes('reversed-species-or-gender'));
60
+ });
61
+
62
+ test('quality assessment rejects repeated generic comparison and false dual-role framing', () => {
63
+ const answer = 'In Blossom แสดงพัฒนาการสลับเล่น 2 คาแรคเตอร์ได้อย่างน่าทึ่ง ส่วน Sword and Fairy 4 เป็นงานปี 2024 ★ 9.5 / 10';
64
+ const assessment = assessDramaAnswerQuality(
65
+ 'ดำเนินการต่อ: เปรียบเทียบซีรีส์ปี 2024 กับผลงานไอดอลในอดีต',
66
+ answer
67
+ );
68
+
69
+ assert.equal(assessment.valid, false);
70
+ assert.ok(assessment.issues.includes('false-dual-role-framing'));
71
+ assert.ok(assessment.issues.includes('unattributed-rating'));
72
+ assert.ok(assessment.issues.includes('missing-past-work-evidence'));
73
+ });
@@ -0,0 +1,285 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdtemp, rm } from 'node:fs/promises';
6
+
7
+ import {
8
+ loadDynamicCatalog,
9
+ saveDynamicCatalog,
10
+ validateCatalogEntry,
11
+ addDynamicEntry,
12
+ getDynamicEntries,
13
+ clearDynamicCatalog,
14
+ getDynamicCatalogStats,
15
+ pruneExpiredEntries
16
+ } from '../dynamicCatalog.js';
17
+
18
+ import {
19
+ isTrustedSource,
20
+ computeEventSimHash,
21
+ isDuplicateEvent,
22
+ extractStructuredEvent,
23
+ ingestRawEvent,
24
+ runIngestionScan
25
+ } from '../ingestionWorker.js';
26
+
27
+ import { executeTool } from '../tools.js';
28
+ import { routeCommand } from '../commands.js';
29
+
30
+ test('Dynamic Catalog: validation and CRUD operations', async (t) => {
31
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-dynamic-test-'));
32
+ const catalogPath = path.join(tmpDir, 'dynamic-catalog.json');
33
+
34
+ await t.test('validateCatalogEntry validates categories and required titles', () => {
35
+ assert.equal(validateCatalogEntry('brands', { brand: 'Lafang' }).valid, true);
36
+ assert.equal(validateCatalogEntry('invalid_cat', { title: 'Test' }).valid, false);
37
+ assert.equal(validateCatalogEntry('works', {}).valid, false);
38
+ assert.equal(validateCatalogEntry('timeline', { title: 'Milestone 2026' }).valid, true);
39
+ });
40
+
41
+ await t.test('addDynamicEntry persists and assigns clean IDs and metadata', () => {
42
+ const entry = addDynamicEntry('brands', {
43
+ brand: 'TuTuCare',
44
+ brandNameEn: 'TuTuCare',
45
+ brandNameCn: '途途呵护',
46
+ year: 2024,
47
+ role: 'Brand Ambassador',
48
+ highlight: 'Pet and rabbit care ambassador'
49
+ }, { catalogPath, trustTier: 1, source: 'official_weibo' });
50
+
51
+ assert.ok(entry.id.startsWith('dyn-bra-'));
52
+ assert.equal(entry.brand, 'TuTuCare');
53
+ assert.equal(entry.trustTier, 1);
54
+ assert.equal(entry.year, 2024);
55
+
56
+ const brands = getDynamicEntries('brands', null, catalogPath);
57
+ assert.equal(brands.length, 1);
58
+ assert.equal(brands[0].id, entry.id);
59
+
60
+ const stats = getDynamicCatalogStats(catalogPath);
61
+ assert.equal(stats.totalEntries, 1);
62
+ assert.equal(stats.brandsCount, 1);
63
+ });
64
+
65
+ await t.test('pruneExpiredEntries removes expired entries and retains valid ones', () => {
66
+
67
+ const eightDaysAgo = new Date(Date.now() - 8 * 24 * 3600 * 1000).toISOString();
68
+ addDynamicEntry('events', {
69
+ id: 'old-event-1',
70
+ title: 'Old Live Stream',
71
+ createdAt: eightDaysAgo
72
+ }, { catalogPath });
73
+
74
+ const statsBefore = getDynamicCatalogStats(catalogPath);
75
+ assert.equal(statsBefore.eventsCount, 1);
76
+
77
+ const pruned = pruneExpiredEntries({ catalogPath, ttlDays: 7 });
78
+ assert.equal(pruned.prunedCount, 1);
79
+
80
+ const statsAfter = getDynamicCatalogStats(catalogPath);
81
+ assert.equal(statsAfter.eventsCount, 0);
82
+ });
83
+
84
+ await t.test('clearDynamicCatalog empties all collections', () => {
85
+ clearDynamicCatalog(catalogPath);
86
+ const stats = getDynamicCatalogStats(catalogPath);
87
+ assert.equal(stats.totalEntries, 0);
88
+ });
89
+
90
+ await rm(tmpDir, { recursive: true, force: true });
91
+ });
92
+
93
+ test('Ingestion Worker: trust tiers, deduplication, and extraction', async (t) => {
94
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-ingest-test-'));
95
+ const catalogPath = path.join(tmpDir, 'dynamic-catalog.json');
96
+
97
+ await t.test('isTrustedSource classifies official platforms and press', () => {
98
+ const douyin = isTrustedSource('douyin');
99
+ assert.equal(douyin.trusted, true);
100
+ assert.equal(douyin.tier, 1);
101
+
102
+ const weiboUrl = isTrustedSource('https://weibo.com/u/123456');
103
+ assert.equal(weiboUrl.trusted, true);
104
+ assert.equal(weiboUrl.tier, 1);
105
+
106
+ const press = isTrustedSource('https://www.sohu.com/a/123456');
107
+ assert.equal(press.trusted, true);
108
+ assert.equal(press.tier, 2);
109
+
110
+ const community = isTrustedSource('https://randomblog.com/post/123');
111
+ assert.equal(community.trusted, false);
112
+ assert.equal(community.tier, 3);
113
+ });
114
+
115
+ await t.test('isDuplicateEvent detects identical and near-duplicate entries via SimHash', () => {
116
+ const existing = [
117
+ {
118
+ id: 'item-1',
119
+ brand: 'Lafang',
120
+ year: 2026,
121
+ highlight: 'จวีจิ้งอี ไลฟ์สดแบรนด์ Lafang บน Douyin'
122
+ }
123
+ ];
124
+
125
+ const exactMatch = { brand: 'Lafang', year: 2026 };
126
+ assert.equal(isDuplicateEvent(exactMatch, existing), true);
127
+
128
+ const simHashMatch = {
129
+ brand: 'Other',
130
+ year: 2026,
131
+ highlight: 'จวีจิ้งอี ไลฟ์สดแบรนด์ Lafang บน Douyin ในลุคนางฟ้า'
132
+ };
133
+ assert.equal(isDuplicateEvent(simHashMatch, existing), true);
134
+
135
+ const different = {
136
+ brand: 'FRED Paris',
137
+ year: 2024,
138
+ highlight: 'เครื่องประดับชั้นสูงจากฝรั่งเศส'
139
+ };
140
+ assert.equal(isDuplicateEvent(different, existing), false);
141
+ });
142
+
143
+ await t.test('extractStructuredEvent correctly parses Thai date and brand entity', async () => {
144
+ const sample = 'คืนวันที่ 12 กันยายน จวีจิ้งอี ไลฟ์สดงานแบรนด์ Lafang (拉芳) บนแพลตฟอร์ม Douyin ด้วยลุคนางฟ้าดอกไม้';
145
+ const res = await extractStructuredEvent(sample);
146
+
147
+ assert.equal(res.category, 'brands');
148
+ assert.equal(res.year, 2026);
149
+ assert.equal(res.date, '2026-09-12');
150
+ assert.equal(res.brand, 'Lafang');
151
+ assert.equal(res.brandNameCn, '拉芳');
152
+ });
153
+
154
+ await t.test('ingestRawEvent rejects untrusted source and ingests trusted source', async () => {
155
+ const text = 'จวีจิ้งอี ร่วมงานอีเวนต์พิเศษ';
156
+
157
+
158
+ const rejected = await ingestRawEvent(text, {
159
+ source: 'https://unverified-blog.org',
160
+ catalogPath
161
+ });
162
+ assert.equal(rejected.status, 'rejected');
163
+
164
+ const success = await ingestRawEvent(text, {
165
+ source: 'douyin',
166
+ catalogPath
167
+ });
168
+ assert.equal(success.status, 'success');
169
+
170
+
171
+ const dup = await ingestRawEvent(text, {
172
+ source: 'douyin',
173
+ catalogPath
174
+ });
175
+ assert.equal(dup.status, 'duplicate');
176
+ });
177
+
178
+ await t.test('runIngestionScan scans and ingests verified candidates', async () => {
179
+ const mockFetcher = async () => [
180
+ {
181
+ title: 'Ju Jingyi attends Luxury Gala',
182
+ snippet: 'Official appearance at annual fashion gala',
183
+ url: 'https://weibo.com/detail/999'
184
+ },
185
+ {
186
+ title: 'Ju Jingyi fan forum post',
187
+ snippet: 'Unverified rumor from forum',
188
+ url: 'https://randomforum.xyz/topic/1'
189
+ }
190
+ ];
191
+
192
+ const result = await runIngestionScan({
193
+ fetcher: mockFetcher,
194
+ catalogPath,
195
+ minTrustTier: 2
196
+ });
197
+
198
+ assert.equal(result.totalScanned, 2);
199
+ assert.equal(result.ingestedCount, 1);
200
+ assert.equal(result.skippedUntrusted, 1);
201
+ });
202
+
203
+ await rm(tmpDir, { recursive: true, force: true });
204
+ });
205
+
206
+ test('Core Tools: Dynamic Catalog Overlay integration and record_dynamic_event tool', async () => {
207
+
208
+ clearDynamicCatalog();
209
+
210
+
211
+ const recordRes = await executeTool('record_dynamic_event', {
212
+ category: 'brands',
213
+ entry: {
214
+ brand: 'TestDynamicBrand',
215
+ brandNameEn: 'TestDynamicBrand',
216
+ brandNameCn: '测试品牌',
217
+ year: 2026,
218
+ category: 'beauty',
219
+ highlight: 'Special Dynamic Brand Ambassador'
220
+ },
221
+ source: 'weibo',
222
+ trustTier: 1
223
+ });
224
+
225
+ assert.equal(recordRes.status, 'success');
226
+ assert.equal(recordRes.entry.brand, 'TestDynamicBrand');
227
+
228
+
229
+ const brandsRes = await executeTool('get_brand_endorsements', { query: 'TestDynamicBrand' });
230
+ assert.equal(brandsRes.status, 'success');
231
+ assert.ok(brandsRes.brands.some(b => b.brand.includes('TestDynamicBrand')));
232
+
233
+
234
+ await executeTool('record_dynamic_event', {
235
+ category: 'timeline',
236
+ entry: {
237
+ title: 'Dynamic Milestone 2026',
238
+ titleCn: '动态里程碑',
239
+ year: 2026,
240
+ summary: 'Special dynamic timeline entry test'
241
+ }
242
+ });
243
+
244
+ const timelineRes = await executeTool('get_career_timeline', { year: 2026 });
245
+ assert.equal(timelineRes.status, 'success');
246
+ assert.ok(timelineRes.milestones.some(m => m.title.includes('Dynamic Milestone 2026')));
247
+
248
+
249
+ await executeTool('record_dynamic_event', {
250
+ category: 'gallery',
251
+ entry: {
252
+ titleTh: 'ภาพพิเศษ 2026',
253
+ titleCn: '动态写真',
254
+ category: 'fashion',
255
+ year: 2026,
256
+ image: 'https://example.com/dynamic-photo.jpg'
257
+ }
258
+ });
259
+
260
+ const galleryRes = await executeTool('get_gallery_photos', { year: 2026 });
261
+ assert.equal(galleryRes.status, 'success');
262
+ assert.ok(galleryRes.photos.some(p => p.title.includes('ภาพพิเศษ 2026')));
263
+
264
+
265
+ clearDynamicCatalog();
266
+ });
267
+
268
+ test('CLI: /ingest routeCommand handles status, add, and clear', async () => {
269
+ clearDynamicCatalog();
270
+
271
+ const statusRes = await routeCommand('/ingest status', {});
272
+ assert.equal(statusRes, 'HANDLED');
273
+
274
+ const addRes = await routeCommand('/ingest add จวีจิ้งอี ไลฟ์สดงานแบรนด์ Lafang บน Douyin', {});
275
+ assert.equal(addRes, 'HANDLED');
276
+
277
+ const stats = getDynamicCatalogStats();
278
+ assert.ok(stats.totalEntries >= 1);
279
+
280
+ const clearRes = await routeCommand('/ingest clear', {});
281
+ assert.equal(clearRes, 'HANDLED');
282
+
283
+ const statsCleared = getDynamicCatalogStats();
284
+ assert.equal(statsCleared.totalEntries, 0);
285
+ });
@@ -0,0 +1,92 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { resolveQueryEntities, getContextualizedSearchQuery, detectPlatforms, detectPromotionIntent } from '../catalogEntityResolver.js';
4
+ import { DynamicToolRouter } from '../toolRouter.js';
5
+ import { createCatalogMatcher, executeTool } from '../tools.js';
6
+
7
+ test('catalogEntityResolver resolves entities across languages without rigid keywords', () => {
8
+ const prsrQuery = 'ค้นหาการโปรโมท PRSR บน bilibili';
9
+ const prsrRes = resolveQueryEntities(prsrQuery);
10
+
11
+ assert.equal(prsrRes.hasBrand, true);
12
+ assert.ok(prsrRes.primaryBrand);
13
+ assert.equal(prsrRes.primaryBrand.id, 'prsr');
14
+ assert.ok(prsrRes.platforms.includes('bilibili'));
15
+ assert.equal(prsrRes.isPromotion, true);
16
+ assert.ok(prsrRes.contextualChineseKeywords.includes('帕莎眼镜'));
17
+
18
+ const pashaQuery = 'ดูคลิปโฆษณา pasha ใน b站';
19
+ const pashaRes = resolveQueryEntities(pashaQuery);
20
+ assert.equal(pashaRes.hasBrand, true);
21
+ assert.equal(pashaRes.primaryBrand.id, 'prsr');
22
+ assert.ok(pashaRes.platforms.includes('bilibili'));
23
+
24
+ const qixingcaiQuery = 'ผลงานชีซิงไฉ่';
25
+ const qixingcaiRes = resolveQueryEntities(qixingcaiQuery);
26
+ assert.equal(qixingcaiRes.hasWork, true);
27
+ assert.equal(qixingcaiRes.primaryWork.nameCn, '七星彩');
28
+
29
+ const inBlossomQuery = 'เพลงประกอบฮวาเจียนลิ่ง';
30
+ const inBlossomRes = resolveQueryEntities(inBlossomQuery);
31
+ assert.equal(inBlossomRes.hasWork, true);
32
+ assert.equal(inBlossomRes.primaryWork.nameCn, '花间令');
33
+ });
34
+
35
+ test('getContextualizedSearchQuery enriches Chinese queries with artist and brand aliases', () => {
36
+ const biliPrsr = getContextualizedSearchQuery('PRSR', 'bilibili');
37
+ assert.ok(biliPrsr.includes('鞠婧祎'), 'Must include artist name in Chinese');
38
+ assert.ok(biliPrsr.includes('帕莎'), 'Must include Chinese brand name');
39
+
40
+ const biliPrompt = getContextualizedSearchQuery('ค้นหาการโปรโมท PRSR บน bilibili', 'bilibili');
41
+ assert.ok(biliPrompt.includes('鞠婧祎'));
42
+ assert.ok(biliPrompt.includes('帕莎'));
43
+ assert.ok(biliPrompt.includes('PRSR'));
44
+
45
+ const dramaQuery = getContextualizedSearchQuery('In Blossom', 'bilibili');
46
+ assert.ok(dramaQuery.includes('鞠婧祎'));
47
+ assert.ok(dramaQuery.includes('花间令'));
48
+ });
49
+
50
+ test('DynamicToolRouter routes brand and media tools simultaneously on cross-cutting queries', () => {
51
+ const router = new DynamicToolRouter();
52
+
53
+ const routed = router.routeToolsForQuery('ค้นหาการโปรโมท PRSR บน bilibili');
54
+ const toolNames = new Set(routed.map(t => t.function.name));
55
+
56
+ assert.ok(toolNames.has('get_brand_endorsements'), 'Must include get_brand_endorsements');
57
+ assert.ok(toolNames.has('search_bilibili'), 'Must include search_bilibili');
58
+ assert.ok(toolNames.has('get_media_links'), 'Must include get_media_links');
59
+ assert.ok(routed.length <= 15);
60
+
61
+ const fallback = router.routeToolsForQuery('สวัสดีครับ');
62
+ assert.ok(fallback.length <= 10, 'Fallback tools must not exceed 10');
63
+ assert.ok(fallback.some(t => t.function.name === 'global_search'));
64
+ assert.ok(fallback.some(t => t.function.name === 'get_brand_endorsements'));
65
+ });
66
+
67
+ test('createCatalogMatcher handles promotional query framing without token ratio penalty', () => {
68
+ const matcher = createCatalogMatcher('ค้นหาการโปรโมท PRSR บน bilibili');
69
+ assert.equal(matcher('PRSR / Pasha', '帕莎眼镜', 'Global Brand Ambassador'), true);
70
+
71
+ const yunxiMatcher = createCatalogMatcher('เพลง Ost Ju Jingyi 2024 Yunxi');
72
+ assert.equal(yunxiMatcher('ตำนานหยุนซี Legend of Yunxi', 'Yunxi'), true);
73
+ });
74
+
75
+ test('get_brand_endorsements tool returns bilibiliSearchUrl and handles query filter', async () => {
76
+ const res = await executeTool('get_brand_endorsements', { query: 'PRSR' });
77
+ assert.equal(res.status, 'success');
78
+ assert.ok(res.count >= 1);
79
+ const prsr = res.brands[0];
80
+ assert.ok(prsr.brand.includes('PRSR') || prsr.brand.includes('Pasha'));
81
+ assert.ok(prsr.bilibiliSearchUrl, 'Must include bilibiliSearchUrl');
82
+ assert.ok(prsr.bilibiliSearchUrl.includes('search.bilibili.com'));
83
+ assert.ok(prsr.bilibiliSearchUrl.includes(encodeURIComponent('鞠婧祎')));
84
+ });
85
+
86
+ test('get_media_links tool searches brand commercial links alongside works and music', async () => {
87
+ const res = await executeTool('get_media_links', { query: 'PRSR' });
88
+ assert.equal(res.status, 'success');
89
+ assert.ok(res.results.brands.length >= 1, 'Should find PRSR in brand media results');
90
+ assert.ok(res.results.brands[0].bilibiliSearchUrl);
91
+ assert.ok(res.results.brands[0].youtubeCommercialSearch);
92
+ });
@@ -0,0 +1,49 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { sanitizeQuery } from '../sanitizer.js';
4
+ import { DynamicToolRouter } from '../toolRouter.js';
5
+ import { executeTool } from '../tools.js';
6
+ import { ContextCompactor } from '../contextCompactor.js';
7
+
8
+ test('Edge Cases Suite: Sanitizer handles empty, whitespace, and extreme length strings gracefully', () => {
9
+ const emptyRes = sanitizeQuery('');
10
+ assert.equal(emptyRes.sanitized, '');
11
+
12
+ const spaceRes = sanitizeQuery(' \t\n ');
13
+ assert.equal(spaceRes.sanitized, '');
14
+
15
+ const longText = 'A'.repeat(40000);
16
+ const longRes = sanitizeQuery(longText);
17
+ assert.ok(longRes.sanitized.length <= 32768);
18
+ });
19
+
20
+ test('Edge Cases Suite: DynamicToolRouter returns safe fallback when given empty or strange inputs', () => {
21
+ const router = new DynamicToolRouter();
22
+ const nullRouted = router.routeToolsForQuery(null);
23
+ assert.ok(nullRouted.length > 0);
24
+
25
+ const emptyRouted = router.routeToolsForQuery('');
26
+ assert.ok(emptyRouted.length > 0);
27
+
28
+ const weirdRouted = router.routeToolsForQuery('!@#$%^&*()_+~`|}{[]:;?><,./');
29
+ assert.ok(weirdRouted.length > 0);
30
+ });
31
+
32
+ test('Edge Cases Suite: Catalog tools return not_found status instead of throwing on unknown records', async () => {
33
+ const notFoundWork = await executeTool('search_works', { query: 'non_existent_drama_xyz_99999' });
34
+ assert.equal(notFoundWork.status, 'not_found');
35
+ assert.equal(notFoundWork.count, 0);
36
+
37
+ const notFoundSong = await executeTool('search_discography', { query: 'non_existent_song_xyz_99999' });
38
+ assert.equal(notFoundSong.status, 'not_found');
39
+ assert.equal(notFoundSong.count, 0);
40
+ });
41
+
42
+ test('Edge Cases Suite: ContextCompactor handles empty, malformed, or oversized history arrays', () => {
43
+ const compactor = new ContextCompactor({ maxTokens: 100, keepRecentMessages: 2 });
44
+ assert.deepEqual(compactor.compact([]), []);
45
+ assert.deepEqual(compactor.compact(null), null);
46
+
47
+ const singleMsg = [{ role: 'user', content: 'Hello' }];
48
+ assert.deepEqual(compactor.compact(singleMsg), singleMsg);
49
+ });