kiku-agent 1.3.1 → 1.3.3

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 (232) hide show
  1. package/README.md +69 -12
  2. package/package.json +5 -12
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/AGENTS.md +228 -0
  18. package/server/agent/eval/analyze.py +684 -0
  19. package/server/agent/eval/gates.json +15 -0
  20. package/server/agent/eval/modules/analysis/compare.js +198 -0
  21. package/server/agent/eval/modules/analysis/gates.js +72 -0
  22. package/server/agent/eval/modules/analysis/judge.js +70 -0
  23. package/server/agent/eval/modules/core/cli.js +453 -0
  24. package/server/agent/eval/modules/core/config.js +60 -0
  25. package/server/agent/eval/modules/core/store.js +236 -0
  26. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  27. package/server/agent/eval/modules/evaluation/runner.js +288 -0
  28. package/server/agent/eval/modules/evaluation/scoring.js +363 -0
  29. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  30. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  31. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  32. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  33. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  34. package/server/agent/eval/modules/ui/evalUi.js +4799 -0
  35. package/server/agent/eval/modules/utils/smokes.js +119 -0
  36. package/server/agent/eventBus.js +1 -0
  37. package/server/agent/ink/main.jsx +196 -191
  38. package/server/agent/jobManager.js +11 -1
  39. package/server/agent/lanDiscovery.js +721 -23
  40. package/server/agent/llmClient.js +317 -71
  41. package/server/agent/lmStudioWakeup.js +517 -65
  42. package/server/agent/locationService.js +797 -0
  43. package/server/agent/mcpQuizData.js +509 -0
  44. package/server/agent/memory/contextAllocator.js +19 -7
  45. package/server/agent/memory.js +3 -1
  46. package/server/agent/modelCompatibility.js +352 -0
  47. package/server/agent/modelDefaults.js +1 -0
  48. package/server/agent/modelManager.js +46 -2
  49. package/server/agent/newsWatch.js +9 -3
  50. package/server/agent/processManager.js +229 -0
  51. package/server/agent/prompts.js +174 -8
  52. package/server/agent/routingInterceptor.js +9 -1
  53. package/server/agent/sanitizer.js +41 -6
  54. package/server/agent/searchQueryPlanner.js +202 -0
  55. package/server/agent/session.js +59 -4
  56. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  57. package/server/agent/tests/agentFollowUps.test.js +253 -0
  58. package/server/agent/tests/agentMusicControl.test.js +245 -0
  59. package/server/agent/tests/apiRouter.test.js +108 -0
  60. package/server/agent/tests/architectureFixes.test.js +81 -0
  61. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  62. package/server/agent/tests/autoContext.test.js +176 -0
  63. package/server/agent/tests/autoResearch.test.js +64 -0
  64. package/server/agent/tests/bilibiliEmbed.test.js +296 -0
  65. package/server/agent/tests/catalogSync.test.js +130 -0
  66. package/server/agent/tests/chatHardening.test.js +121 -0
  67. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  68. package/server/agent/tests/citations.test.js +180 -0
  69. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  70. package/server/agent/tests/clarifyingQuestions.test.js +627 -0
  71. package/server/agent/tests/cliAutomation.test.js +158 -0
  72. package/server/agent/tests/cloudProviders.test.js +322 -0
  73. package/server/agent/tests/commandInput.test.js +24 -0
  74. package/server/agent/tests/commands.test.js +506 -0
  75. package/server/agent/tests/contextIntelligence.test.js +209 -0
  76. package/server/agent/tests/conversationSafety.test.js +53 -0
  77. package/server/agent/tests/cpuMetrics.test.js +101 -0
  78. package/server/agent/tests/decisionAndSearchEnhancements.test.js +167 -0
  79. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  80. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  81. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  82. package/server/agent/tests/edgeCasesScenarios.test.js +131 -0
  83. package/server/agent/tests/engineService.test.js +436 -0
  84. package/server/agent/tests/engineUnload.test.js +94 -0
  85. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  86. package/server/agent/tests/envBootstrap.test.js +87 -0
  87. package/server/agent/tests/envCommand.test.js +284 -0
  88. package/server/agent/tests/errorHandling.test.js +80 -0
  89. package/server/agent/tests/errors.test.js +13 -0
  90. package/server/agent/tests/evalAnalyze.test.js +54 -0
  91. package/server/agent/tests/evalCommand.test.js +54 -0
  92. package/server/agent/tests/evalCorpus.test.js +126 -0
  93. package/server/agent/tests/evalHarness.test.js +62 -0
  94. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  95. package/server/agent/tests/evalMetrics.test.js +320 -0
  96. package/server/agent/tests/evalStore.test.js +190 -0
  97. package/server/agent/tests/evalStudio.test.js +179 -0
  98. package/server/agent/tests/feedbackLearning.test.js +186 -0
  99. package/server/agent/tests/fetchApi.test.js +99 -0
  100. package/server/agent/tests/firstRun.test.js +44 -0
  101. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  102. package/server/agent/tests/hardwareTiers.test.js +206 -0
  103. package/server/agent/tests/hybridWebSearch.test.js +194 -0
  104. package/server/agent/tests/judgeService.test.js +251 -0
  105. package/server/agent/tests/lanDiscovery.test.js +324 -0
  106. package/server/agent/tests/lanInterfaces.test.js +510 -0
  107. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  108. package/server/agent/tests/locationTool.test.js +278 -0
  109. package/server/agent/tests/mcpClient.test.js +201 -0
  110. package/server/agent/tests/memoryRoute.test.js +173 -0
  111. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  112. package/server/agent/tests/modelCompatibility.test.js +148 -0
  113. package/server/agent/tests/modelManager.test.js +141 -0
  114. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  115. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  116. package/server/agent/tests/newCommands.test.js +347 -0
  117. package/server/agent/tests/newCommandsTui.test.js +141 -0
  118. package/server/agent/tests/newTools.test.js +113 -0
  119. package/server/agent/tests/newsIntelligence.test.js +178 -0
  120. package/server/agent/tests/nextGenArchitecture.test.js +101 -0
  121. package/server/agent/tests/offlineInput.test.js +217 -0
  122. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  123. package/server/agent/tests/openDomainSearch.test.js +121 -0
  124. package/server/agent/tests/packagingGuard.test.js +57 -0
  125. package/server/agent/tests/presentation.test.js +61 -0
  126. package/server/agent/tests/processManager.test.js +144 -0
  127. package/server/agent/tests/providerPersistence.test.js +44 -0
  128. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  129. package/server/agent/tests/queryDetails.test.js +126 -0
  130. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  131. package/server/agent/tests/realWorldValidation.test.js +391 -0
  132. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  133. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  134. package/server/agent/tests/sanitizer.test.js +37 -0
  135. package/server/agent/tests/scenariosData.js +378 -0
  136. package/server/agent/tests/schemaRouting.test.js +54 -0
  137. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  138. package/server/agent/tests/securityAuditRemediation.test.js +221 -0
  139. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  140. package/server/agent/tests/securityHardening.test.js +146 -0
  141. package/server/agent/tests/securitySanitizer.test.js +38 -0
  142. package/server/agent/tests/sessionAndCopy.test.js +267 -0
  143. package/server/agent/tests/sidebarControls.test.js +135 -0
  144. package/server/agent/tests/statefulMemory.test.js +277 -0
  145. package/server/agent/tests/subagent.test.js +216 -0
  146. package/server/agent/tests/subagentProgress.test.js +109 -0
  147. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  148. package/server/agent/tests/suggestions.test.js +199 -0
  149. package/server/agent/tests/suggestionsTui.test.js +251 -0
  150. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  151. package/server/agent/tests/terminalImage.test.js +80 -0
  152. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  153. package/server/agent/tests/terminalText.test.js +133 -0
  154. package/server/agent/tests/textProtocolLoop.test.js +197 -0
  155. package/server/agent/tests/textToolParsing.test.js +187 -0
  156. package/server/agent/tests/theme.test.js +26 -0
  157. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  158. package/server/agent/tests/toolRoutingRegressions.test.js +160 -0
  159. package/server/agent/tests/tools.test.js +259 -0
  160. package/server/agent/tests/unifiedEndpoints.test.js +159 -0
  161. package/server/agent/tests/v110Features.test.js +262 -0
  162. package/server/agent/tests/versionBump.test.js +69 -0
  163. package/server/agent/tests/vramEstimator.test.js +595 -0
  164. package/server/agent/tests/webChatDocs.test.js +199 -0
  165. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  166. package/server/agent/tests/webChatInspection.test.js +551 -0
  167. package/server/agent/tests/webChatMcp.test.js +94 -0
  168. package/server/agent/tests/webChatMemory.test.js +112 -0
  169. package/server/agent/tests/webChatSecurity.test.js +169 -0
  170. package/server/agent/tests/webExtract.test.js +284 -0
  171. package/server/agent/tests/webSources.test.js +281 -0
  172. package/server/agent/textToolProtocol.js +173 -46
  173. package/server/agent/toolRouter.js +318 -44
  174. package/server/agent/tools.js +877 -96
  175. package/server/agent/ui.js +39 -34
  176. package/server/agent/vramService.js +35 -54
  177. package/server/agent/webSearch.js +441 -162
  178. package/server/agent/websearch/orchestrator.js +15 -5
  179. package/server/agent/websearch/resilience/cache.js +9 -2
  180. package/server/agent/websearch/security/ssrf.js +41 -10
  181. package/server/agent/youtubeAuth.js +5 -20
  182. package/server/apiRouter.js +5558 -4422
  183. package/server/eval/judgeRubrics.js +110 -0
  184. package/server/eval/judgeService.js +290 -0
  185. package/server/eval/runBenchmark.js +182 -0
  186. package/server/mcp/data/discography.js +2 -2
  187. package/server/mcp/examples/antigravity.example.json +17 -0
  188. package/server/mcp/examples/librechat.example.yaml +16 -0
  189. package/server/mcp/handler.js +8 -4
  190. package/server/mcp/security.js +220 -21
  191. package/server/mcp/server.js +2 -1
  192. package/server/mcp/tests/connection.test.js +162 -0
  193. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  194. package/server/mcp/tools.js +7 -1
  195. package/server/supabase_migration_prefix.sql +102 -0
  196. package/server/supabase_schema.sql +63 -0
  197. package/server/web-chat/core/connection.js +179 -218
  198. package/server/web-chat/core/conversationContext.js +9 -2
  199. package/server/web-chat/core/directCatalogEngine.js +52 -28
  200. package/server/web-chat/core/dramaKnowledge.js +33 -22
  201. package/server/web-chat/core/followUpEngine.js +735 -81
  202. package/server/web-chat/core/futureEventGuard.js +5 -1
  203. package/server/web-chat/core/musicControlAction.js +70 -0
  204. package/server/web-chat/core/security.js +32 -21
  205. package/server/web-chat/core/toolInspector.js +6 -6
  206. package/server/web-chat/core/urlGuard.js +45 -0
  207. package/server/web-chat/router.js +204 -157
  208. package/server/web-chat/routes/chatRoute.js +885 -488
  209. package/server/web-chat/routes/engineRoute.js +270 -0
  210. package/server/web-chat/routes/exportRoute.js +1 -1
  211. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  212. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  213. package/server/web-chat/routes/mcpRoute.js +386 -0
  214. package/server/web-chat/routes/memoryRoute.js +253 -0
  215. package/server/web-chat/routes/providerRoute.js +49 -16
  216. package/server/web-chat/routes/staticRoute.js +306 -17
  217. package/server/web-chat/routes/statusRoute.js +194 -73
  218. package/server/web-chat/routes/suggestRoute.js +11 -3
  219. package/server/web-chat/routes/verifyRoute.js +26 -4
  220. package/server/web-chat/server.js +7 -6
  221. package/server/web-chat/ui/docsBuilder.js +3558 -0
  222. package/server/web-chat/ui/htmlBuilder.js +12 -0
  223. package/server/web-chat/ui/scripts/client.js +3401 -1302
  224. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  225. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  226. package/server/web-chat/ui/styles/lan-card.css +165 -0
  227. package/server/web-chat/ui/styles/main.css +4289 -447
  228. package/server/web-chat/ui/sw.js +64 -0
  229. package/server/web-chat/ui/templates/body.html +407 -281
  230. package/src/data/awardsData.js +146 -146
  231. package/src/data/musicVideoData.js +6 -4
  232. package/src/lib/vram/estimator.js +195 -19
@@ -0,0 +1,112 @@
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, readFile, rm } from 'node:fs/promises';
6
+
7
+ import { KikuAgent } from '../agentCore.js';
8
+ import { McpClientManager } from '../mcpClient.js';
9
+ import { AgentSession } from '../session.js';
10
+ import { AgentMemory } from '../memory.js';
11
+
12
+ function createMemoryStub() {
13
+ return {
14
+ _loaded: true,
15
+ _storageError: null,
16
+ facts: [],
17
+ preferences: { userSkills: [], skillsEnabled: true, topicsOfInterest: [] },
18
+ buildMemoryContext: () => '',
19
+ buildUserSkillContext: () => '',
20
+ trackTopicInterest: () => {},
21
+ getArchivedSessions: () => [],
22
+ getStatus: () => ({ factsCount: 0, sessionsCount: 0 }),
23
+ addFact: () => null,
24
+ };
25
+ }
26
+
27
+ test('AgentSession keeps web chat storage files isolated', async () => {
28
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-web-sessions-'));
29
+ const firstPath = path.join(tempDir, 'first.json');
30
+ const secondPath = path.join(tempDir, 'second.json');
31
+
32
+ try {
33
+ const first = new AgentSession({ sessionId: 'web_first', storageFile: firstPath });
34
+ first.createUserMessage('ข้อความของห้องแรก');
35
+ await first.saveToDisk();
36
+
37
+ const second = new AgentSession({ sessionId: 'web_second', storageFile: secondPath });
38
+ second.createUserMessage('ข้อความของห้องที่สอง');
39
+ await second.saveToDisk();
40
+
41
+ const loadedFirst = new AgentSession({ storageFile: firstPath });
42
+ const loadedSecond = new AgentSession({ storageFile: secondPath });
43
+ assert.equal(await loadedFirst.loadFromDisk(), true);
44
+ assert.equal(await loadedSecond.loadFromDisk(), true);
45
+ assert.equal(loadedFirst.messages[0].parts[0].content, 'ข้อความของห้องแรก');
46
+ assert.equal(loadedSecond.messages[0].parts[0].content, 'ข้อความของห้องที่สอง');
47
+ } finally {
48
+ await rm(tempDir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test('AgentMemory reports fact counts by category', () => {
53
+ const memory = new AgentMemory();
54
+ memory.facts = [
55
+ { category: 'music' },
56
+ { category: 'music' },
57
+ { category: 'drama' },
58
+ {},
59
+ ];
60
+
61
+ assert.deepEqual(memory.getFactsByCategory(), { music: 2, drama: 1, general: 1 });
62
+ assert.deepEqual(memory.getStatus().factsByCategory, { music: 2, drama: 1, general: 1 });
63
+ });
64
+
65
+ test('KikuAgent hydrates browser history and persists direct answers', async () => {
66
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kiku-web-memory-'));
67
+ const storageFile = path.join(tempDir, 'session.json');
68
+ const observedMessages = [];
69
+ const client = {
70
+ provider: 'lmstudio',
71
+ providerName: 'LM Studio',
72
+ model: 'test-model',
73
+ async chat(options) {
74
+ observedMessages.push(options.messages.map(message => ({ ...message })));
75
+ options.onToken?.('ฉันจำได้ว่าชื่อที่บอกคือ Miju');
76
+ return { role: 'assistant', content: 'ฉันจำได้ว่าชื่อที่บอกคือ Miju' };
77
+ }
78
+ };
79
+ const agent = new KikuAgent({
80
+ client,
81
+ memory: createMemoryStub(),
82
+ mcpManager: new McpClientManager({
83
+ autoLoad: false,
84
+ globalConfigPath: path.join(tempDir, 'mcp.json'),
85
+ }),
86
+ sessionId: 'web_memory_test',
87
+ sessionStorageFile: storageFile,
88
+ });
89
+
90
+ try {
91
+ await agent.ready();
92
+ const hydration = await agent.hydrateConversationHistory([
93
+ { role: 'user', text: 'เรียกฉันว่าอะไรดี' },
94
+ { role: 'bot', text: 'ฉันจะเรียกคุณว่า Miju' },
95
+ ], { sessionId: 'web_memory_test' });
96
+ assert.equal(hydration.hydrated, true);
97
+
98
+ const answer = await agent.run('ฉันบอกให้เรียกว่าอะไร');
99
+ assert.equal(answer, 'ฉันจำได้ว่าชื่อที่บอกคือ Miju');
100
+ assert.ok(observedMessages[0].some(message =>
101
+ message.role === 'assistant' && message.content === 'ฉันจะเรียกคุณว่า Miju'
102
+ ));
103
+
104
+ const saved = JSON.parse(await readFile(storageFile, 'utf8'));
105
+ assert.equal(saved.id, 'web_memory_test');
106
+ assert.equal(saved.messages.length, 4);
107
+ assert.equal(saved.messages.at(-1).parts[0].content, 'ฉันจำได้ว่าชื่อที่บอกคือ Miju');
108
+ } finally {
109
+ await agent.mcpManager.disconnectAll();
110
+ await rm(tempDir, { recursive: true, force: true });
111
+ }
112
+ });
@@ -0,0 +1,169 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { readFileSync } from 'node:fs';
5
+ import {
6
+ resolveAllowedOrigin,
7
+ applySecurityHeaders,
8
+ extractApiKey,
9
+ checkChatRateLimit,
10
+ resolveClientIp
11
+ } from '../../web-chat/core/security.js';
12
+ import { handleWebChatRequest } from '../../web-chat/router.js';
13
+ import { getSharedAgent } from '../../routes/agent.js';
14
+
15
+ test('Security: resolveAllowedOrigin correctly validates allowed origins', () => {
16
+ assert.equal(resolveAllowedOrigin('http://localhost:3000'), 'http://localhost:3000');
17
+ assert.equal(resolveAllowedOrigin('http://127.0.0.1:5173'), 'http://127.0.0.1:5173');
18
+ assert.equal(resolveAllowedOrigin('https://jujingyi-fans.vercel.app'), null);
19
+ assert.equal(resolveAllowedOrigin(null), null);
20
+ assert.equal(resolveAllowedOrigin(''), null);
21
+ });
22
+
23
+ test('Security: resolveAllowedOrigin respects custom WEBCHAT_CORS_ORIGINS', () => {
24
+ const origEnv = process.env.WEBCHAT_CORS_ORIGINS;
25
+ try {
26
+ process.env.WEBCHAT_CORS_ORIGINS = 'https://portal.kiku.com, https://admin.kiku.com';
27
+ assert.equal(resolveAllowedOrigin('https://portal.kiku.com'), 'https://portal.kiku.com');
28
+ assert.equal(resolveAllowedOrigin('https://admin.kiku.com'), 'https://admin.kiku.com');
29
+ assert.equal(resolveAllowedOrigin('https://evil-site.com'), null);
30
+ } finally {
31
+ if (origEnv !== undefined) process.env.WEBCHAT_CORS_ORIGINS = origEnv;
32
+ else delete process.env.WEBCHAT_CORS_ORIGINS;
33
+ }
34
+ });
35
+
36
+ test('Security: applySecurityHeaders injects standard defensive headers', () => {
37
+ const headers = {};
38
+ const mockReq = { headers: { origin: 'http://localhost:3000' } };
39
+ const mockRes = {
40
+ setHeader: (k, v) => { headers[k.toLowerCase()] = v; }
41
+ };
42
+
43
+ applySecurityHeaders(mockReq, mockRes);
44
+ assert.equal(headers['x-content-type-options'], 'nosniff');
45
+ assert.equal(headers['x-frame-options'], 'SAMEORIGIN');
46
+ assert.equal(headers['referrer-policy'], 'strict-origin-when-cross-origin');
47
+ assert.equal(headers['access-control-allow-origin'], 'http://localhost:3000');
48
+ assert.match(headers['access-control-allow-methods'], /GET, POST, OPTIONS/);
49
+ assert.match(headers['access-control-allow-headers'], /Authorization/);
50
+ });
51
+
52
+ test('Security: extractApiKey extracts tokens securely with priority', () => {
53
+
54
+ const req1 = { headers: { 'x-api-key': 'key_from_x_header' } };
55
+ assert.equal(extractApiKey(req1), 'key_from_x_header');
56
+
57
+
58
+ const req2 = { headers: { authorization: 'Bearer key_from_bearer' } };
59
+ assert.equal(extractApiKey(req2), 'key_from_bearer');
60
+
61
+
62
+ const req3 = { headers: { 'x-api-key': 'key_header' } };
63
+ const url3 = new URL('http://localhost:3000/api/chat?apiKey=key_query');
64
+ assert.equal(extractApiKey(req3, url3), 'key_header');
65
+
66
+
67
+ const req4 = { headers: {} };
68
+ const url4 = new URL('http://localhost:3000/api/chat?apiKey=key_query');
69
+ assert.equal(extractApiKey(req4, url4), null);
70
+
71
+
72
+ assert.equal(extractApiKey({ headers: {} }, new URL('http://localhost:3000/api/chat')), null);
73
+ });
74
+
75
+ test('Security: client IP ignores spoofed forwarding headers unless a trusted proxy is configured', () => {
76
+ const original = process.env.KIKU_TRUST_PROXY;
77
+ const originalVercel = process.env.VERCEL;
78
+ try {
79
+ delete process.env.KIKU_TRUST_PROXY;
80
+ delete process.env.VERCEL;
81
+ const req = { headers: { 'x-forwarded-for': '10.0.0.5' }, socket: { remoteAddress: '198.51.100.20' } };
82
+ assert.equal(resolveClientIp(req), '198.51.100.20');
83
+ } finally {
84
+ if (original === undefined) delete process.env.KIKU_TRUST_PROXY;
85
+ else process.env.KIKU_TRUST_PROXY = original;
86
+ if (originalVercel === undefined) delete process.env.VERCEL;
87
+ else process.env.VERCEL = originalVercel;
88
+ }
89
+ });
90
+
91
+ test('Security: remotely reachable agents are isolated and cannot access privileged tools', () => {
92
+ const first = getSharedAgent();
93
+ const second = getSharedAgent();
94
+ assert.notEqual(first, second);
95
+ assert.notEqual(first.memory, second.memory);
96
+ const names = new Set(first.getAllToolsSchema().map(tool => tool.function?.name || tool.name));
97
+ assert.equal(names.has('read_local_file'), false);
98
+ assert.equal(names.has('manage_skill'), false);
99
+ assert.equal(names.has('fetch_json'), false);
100
+ });
101
+
102
+ test('Security: web-chat escapes markdown before rendering and never persists API keys in browser storage', () => {
103
+ const source = readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8');
104
+ assert.match(source, /text = escapeHtml\(reconstructed\)/);
105
+ assert.match(source, /data-query=.*this\.dataset\.query/);
106
+ assert.doesNotMatch(source, /localStorage\.setItem\([^\n]*(?:api.?key|vip.?key|token|secret)/i);
107
+ assert.doesNotMatch(source, /body\.apiKey/);
108
+ });
109
+
110
+ test('Security: checkChatRateLimit enforces 60 requests per minute', () => {
111
+ const testIp = '198.51.100.42';
112
+ const first = checkChatRateLimit(testIp);
113
+ assert.equal(first.allowed, true);
114
+ assert.equal(typeof first.remaining, 'number');
115
+
116
+
117
+ for (let i = 0; i < 60; i++) {
118
+ checkChatRateLimit(testIp);
119
+ }
120
+
121
+ const blocked = checkChatRateLimit(testIp);
122
+ assert.equal(blocked.allowed, false);
123
+ assert.ok(blocked.retryAfter > 0);
124
+
125
+
126
+ const otherIpResult = checkChatRateLimit('203.0.113.99');
127
+ assert.equal(otherIpResult.allowed, true);
128
+ });
129
+
130
+ test('Integration: web-chat router handles OPTIONS preflight and rate limiting over HTTP', async () => {
131
+ const server = http.createServer(async (req, res) => {
132
+ await handleWebChatRequest(req, res, { port: 0 });
133
+ });
134
+
135
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
136
+ const port = server.address().port;
137
+ const baseUrl = `http://127.0.0.1:${port}`;
138
+
139
+ try {
140
+
141
+ const optRes = await fetch(`${baseUrl}/api/chat`, {
142
+ method: 'OPTIONS',
143
+ headers: { Origin: 'http://localhost:3000' }
144
+ });
145
+ assert.equal(optRes.status, 204);
146
+ assert.equal(optRes.headers.get('access-control-allow-origin'), 'http://localhost:3000');
147
+ assert.equal(optRes.headers.get('x-content-type-options'), 'nosniff');
148
+
149
+
150
+ const badReq = await fetch(`${baseUrl}/api/chat`, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({})
154
+ });
155
+ assert.equal(badReq.status, 400);
156
+ const badJson = await badReq.json();
157
+ assert.equal(badJson.error, 'Query is required');
158
+
159
+
160
+ const statusRes = await fetch(`${baseUrl}/api/status`, {
161
+ headers: { Authorization: 'Bearer test_agent_key_123' }
162
+ });
163
+ assert.equal(statusRes.status, 200);
164
+ const statusJson = await statusRes.json();
165
+ assert.equal(statusJson.status, 'ok');
166
+ } finally {
167
+ await new Promise((resolve) => server.close(resolve));
168
+ }
169
+ });
@@ -0,0 +1,284 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ decodeHtmlBytes,
5
+ normalizeCharsetLabel,
6
+ decodeEntities,
7
+ normalizeText,
8
+ extractMainContent,
9
+ splitIntoPassages,
10
+ scorePassages,
11
+ selectPassages,
12
+ filterUniquePassages,
13
+ dedupePassages,
14
+ simhashText,
15
+ hamming64,
16
+ tokenizeForMatch,
17
+ truncateByWords,
18
+ } from '../websearch/extract.js';
19
+ import { buildPageResult } from '../webSearch.js';
20
+
21
+ const ARTICLE_FIXTURE = `<!DOCTYPE html>
22
+ <html lang="th">
23
+ <head>
24
+ <title>Ju Jingyi releases new single</title>
25
+ <meta name="description" content="Coverage of the new single release">
26
+ <link rel="canonical" href="https://example.com/news/ju-jingyi-single">
27
+ <script type="application/ld+json">
28
+ {
29
+ "@type": "NewsArticle",
30
+ "headline": "Ju Jingyi releases new single",
31
+ "datePublished": "2026-09-01",
32
+ "author": {"name": "News Desk"},
33
+ "articleBody": "The JSON-LD body of the article."
34
+ }
35
+ </script>
36
+ </head>
37
+ <body>
38
+ <nav class="main-nav"><a href="/home">Home</a><a href="/news">News</a> Navigation bar text</nav>
39
+ <aside class="sidebar">Sidebar promo banner content here</aside>
40
+ <div class="related-stories">Related recommended stories widget</div>
41
+ <article class="post-content">
42
+ <h1>Ju Jingyi releases new single</h1>
43
+ <p>Ju Jingyi released her newest single on September 1st, alongside a full music video filmed in Chongqing. The song debuted at the top of several regional charts within two days.</p>
44
+ <p>Fans praised the choreography and the cinematic production quality of the accompanying video release.</p>
45
+ <h3>Chart performance</h3>
46
+ <p>The single topped three regional charts and stayed in the top ten for two consecutive weeks across multiple platforms.</p>
47
+ <table>
48
+ <tr><th>Year</th><th>Release</th><th>Label</th></tr>
49
+ <tr><td>2017</td><td>Don't Fall in Love</td><td>STAR48</td></tr>
50
+ <tr><td>2024</td><td>0.2s</td><td>Independent</td></tr>
51
+ </table>
52
+ </article>
53
+ <footer class="site-footer">Copyright notice and legal disclaimer footer</footer>
54
+ </body>
55
+ </html>`;
56
+
57
+ test('decodeHtmlBytes resolves charset from the Content-Type header', () => {
58
+ const thaiBytes = Uint8Array.from([0xA1, 0xA2, 0xA3]);
59
+ const decoded = decodeHtmlBytes(thaiBytes, 'text/html; charset=windows-874');
60
+ assert.equal(decoded.charset, 'windows-874');
61
+ assert.equal(decoded.text, 'กขฃ');
62
+ });
63
+
64
+ test('decodeHtmlBytes sniffs legacy meta charset when the header is silent', () => {
65
+ const html = Buffer.from(
66
+ '<html><head><meta charset="windows-874"></head><body>\xA1\xA2\xA3</body></html>',
67
+ 'latin1'
68
+ );
69
+ const decoded = decodeHtmlBytes(html, 'text/html');
70
+ assert.equal(decoded.charset, 'windows-874');
71
+ assert.equal(decoded.text.includes('กขฃ'), true);
72
+ });
73
+
74
+ test('decodeHtmlBytes decodes GBK content', () => {
75
+ const gbkBytes = Uint8Array.from([0xD6, 0xD0]);
76
+ const decoded = decodeHtmlBytes(gbkBytes, 'text/html; charset=gbk');
77
+ assert.equal(decoded.text, '中');
78
+ });
79
+
80
+ test('decodeHtmlBytes falls back to utf-8 for unknown or missing charsets', () => {
81
+ const utf8 = Buffer.from('กขค', 'utf8');
82
+ const missing = decodeHtmlBytes(utf8, 'text/html');
83
+ assert.equal(missing.charset, 'utf-8');
84
+ assert.equal(missing.text, 'กขค');
85
+ const unknown = decodeHtmlBytes(utf8, 'text/html; charset=made-up-charset');
86
+ assert.equal(unknown.text, 'กขค');
87
+ });
88
+
89
+ test('decodeHtmlBytes honors byte order marks over header declarations', () => {
90
+ const bom = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from('กขค', 'utf8')]);
91
+ const decoded = decodeHtmlBytes(bom, 'text/html; charset=windows-874');
92
+ assert.equal(decoded.charset, 'utf-8');
93
+ assert.equal(decoded.text, 'กขค');
94
+ });
95
+
96
+ test('normalizeCharsetLabel maps legacy Thai and Chinese labels', () => {
97
+ assert.equal(normalizeCharsetLabel('TIS-620'), 'windows-874');
98
+ assert.equal(normalizeCharsetLabel('gb2312'), 'gbk');
99
+ assert.equal(normalizeCharsetLabel('ISO-8859-1'), 'windows-1252');
100
+ assert.equal(normalizeCharsetLabel(null), null);
101
+ });
102
+
103
+ test('decodeEntities covers numeric, hex and named entities', () => {
104
+ assert.equal(decodeEntities('&#x2019;'), '’');
105
+ assert.equal(decodeEntities('&#39;'), "'");
106
+ assert.equal(decodeEntities('&hellip;'), '...');
107
+ assert.equal(decodeEntities('&amp;&lt;&gt;'), '&<>');
108
+ assert.equal(decodeEntities('&unknownentity;'), '&unknownentity;');
109
+ });
110
+
111
+ test('normalizeText strips zero-width characters and applies NFC', () => {
112
+ assert.equal(normalizeText('a\u200Bb\u200Dc\uFEFFd'), 'abcd');
113
+ assert.equal(normalizeText('ก \u00E1 b'), 'ก á b');
114
+ assert.equal(normalizeText(' spaced\t\ntext '), 'spaced text');
115
+ });
116
+
117
+ test('extractMainContent isolates the article body from boilerplate', async () => {
118
+ const result = await extractMainContent(ARTICLE_FIXTURE, { url: 'https://example.com/news/ju-jingyi-single' });
119
+
120
+ assert.equal(result.ok ?? true, true);
121
+ assert.equal(result.meta.title, 'Ju Jingyi releases new single');
122
+ assert.equal(result.meta.canonical, 'https://example.com/news/ju-jingyi-single');
123
+
124
+ assert.equal(result.markdown.includes('released her newest single'), true);
125
+ assert.equal(result.markdown.includes('Sidebar promo banner'), false);
126
+ assert.equal(result.markdown.includes('Navigation bar text'), false);
127
+ assert.equal(result.markdown.includes('Related recommended stories'), false);
128
+ assert.equal(result.markdown.includes('Copyright notice and legal disclaimer'), false);
129
+
130
+ assert.equal(result.markdown.includes('## Ju Jingyi releases new single'), true);
131
+ assert.equal(result.markdown.includes('### Chart performance'), true);
132
+
133
+ assert.equal(result.markdown.includes('| Year | Release | Label |'), true);
134
+ assert.equal(result.markdown.includes("| 2017 | Don't Fall in Love | STAR48 |"), true);
135
+ assert.equal(result.markdown.includes('| 2024 | 0.2s | Independent |'), true);
136
+
137
+ assert.equal(result.paragraphs.some(p => p.startsWith('Ju Jingyi released her newest single')), true);
138
+ assert.equal(result.quality.isThin, false);
139
+ assert.ok(result.quality.density > 0);
140
+ });
141
+
142
+ test('extractMainContent parses JSON-LD article metadata', async () => {
143
+ const result = await extractMainContent(ARTICLE_FIXTURE, { url: 'https://example.com/news/ju-jingyi-single' });
144
+ assert.ok(result.jsonLd);
145
+ assert.equal(result.jsonLd.headline, 'Ju Jingyi releases new single');
146
+ assert.equal(result.jsonLd.datePublished, '2026-09-01');
147
+ assert.equal(result.jsonLd.author, 'News Desk');
148
+ assert.equal(result.jsonLd.body, 'The JSON-LD body of the article.');
149
+ });
150
+
151
+ test('extractMainContent flags thin single-page-app shells', async () => {
152
+ const spaShell = `<!DOCTYPE html>
153
+ <html><head><title>App</title></head>
154
+ <body style="padding: 40px; background: linear-gradient(#ffffff 0%, #f8f8f8 100%); min-height: 100vh; font-family: sans-serif;">
155
+ <div id="root"></div>
156
+ <script src="/static/js/bundle.min.js"></script>
157
+ ${'<div class="loading-skeleton placeholder shimmer-block"> </div>'.repeat(400)}
158
+ </body></html>`;
159
+ const result = await extractMainContent(spaShell, { url: 'https://app.example.com/' });
160
+ assert.equal(result.quality.isThin, true);
161
+ assert.ok(result.quality.htmlChars > 15_000);
162
+ });
163
+
164
+ test('buildPageResult reports login-walled/JS-shell pages as actionable errors, not hollow success', () => {
165
+ const empty = buildPageResult(
166
+ { markdown: ' ', meta: {}, engine: 'cheerio-readability', quality: { isThin: true, textChars: 0 } },
167
+ { finalUrl: 'https://weibo.com/u/3669102477', maxLength: 8000, query: '', cached: false }
168
+ );
169
+ assert.equal(empty.status, 'error');
170
+ assert.equal(empty.contentLength, 0);
171
+ assert.equal(empty.extraction.mode, 'no_content');
172
+ assert.match(empty.message, /m\.weibo\.cn/);
173
+ assert.match(empty.message, /search_chinese_web/);
174
+ assert.match(empty.message, /search_news/);
175
+ assert.match(empty.message, /Do NOT present this as the final answer/);
176
+ assert.ok(!('content' in empty) || empty.content === undefined, 'no dead-end sentence for the model to parrot');
177
+
178
+ const ok = buildPageResult(
179
+ { markdown: 'Hello world', meta: {}, engine: 'cheerio-readability', quality: { isThin: false } },
180
+ { finalUrl: 'https://example.com/a', maxLength: 8000, query: '', cached: false }
181
+ );
182
+ assert.equal(ok.status, 'success');
183
+ assert.equal(ok.content, 'Hello world');
184
+ });
185
+
186
+ test('splitIntoPassages bounds sections by headings and size', () => {
187
+ const markdown = [
188
+ '## Section one',
189
+ 'Body of section one.',
190
+ '## Section two',
191
+ 'Body of section two.',
192
+ '## Section three',
193
+ 'Body of section three.',
194
+ ].join('\n');
195
+
196
+ const passages = splitIntoPassages(markdown, { maxChars: 1600 });
197
+ assert.equal(passages.length, 3);
198
+ assert.equal(passages[0].heading, '## Section one');
199
+ assert.equal(passages[1].text.startsWith('## Section two'), true);
200
+ assert.equal(passages[2].index, 2);
201
+ });
202
+
203
+ test('selectPassages ranks sections by query relevance and preserves document order', () => {
204
+ const markdown = [
205
+ '## Music releases',
206
+ 'The discography lists many albums and singles from past years with varied labels and formats.',
207
+ '## Cooking recipes',
208
+ 'Boil the noodles for four minutes and mix the sauce gently over low heat before serving.',
209
+ '## Chart performance',
210
+ 'The 0.2s single topped three regional charts and stayed in the top ten for two weeks.',
211
+ ].join('\n\n');
212
+
213
+ const passages = splitIntoPassages(markdown);
214
+ const selection = selectPassages(passages, '0.2s single chart performance', { budgetChars: 150 });
215
+
216
+ assert.equal(selection.total, 3);
217
+ assert.ok(selection.selected >= 1);
218
+ assert.ok(selection.selected < selection.total, 'tight budget must exclude low-relevance sections');
219
+ assert.equal(selection.text.includes('topped three regional charts'), true);
220
+ assert.equal(selection.text.includes('Boil the noodles'), false);
221
+ const chartPos = selection.text.indexOf('Chart performance');
222
+ const musicPos = selection.text.indexOf('Music releases');
223
+ if (musicPos !== -1 && chartPos !== -1) {
224
+ assert.ok(musicPos < chartPos, 'selected passages must follow document order');
225
+ }
226
+ });
227
+
228
+ test('scorePassages gives zero to empty queries without crashing', () => {
229
+ const passages = splitIntoPassages('## A\n\nhello world content here');
230
+ const scored = scorePassages(passages, '');
231
+ assert.equal(scored.length, 1);
232
+ assert.equal(scored[0].score, 0);
233
+ });
234
+
235
+ test('tokenizeForMatch segments Thai words and uses CJK bigrams', () => {
236
+ const thaiTokens = tokenizeForMatch('จวีจิ้งอีปล่อยเพลงใหม่', 'th');
237
+ assert.ok(thaiTokens.length > 1, 'Thai text must be segmented into multiple words');
238
+ const zhTokens = tokenizeForMatch('鞠婧祎新专辑', 'zh');
239
+ assert.ok(zhTokens.length >= 3, 'Chinese text must produce character bigrams');
240
+ const latin = tokenizeForMatch('Ju Jingyi chart', 'en');
241
+ assert.deepEqual(latin, ['ju', 'jingyi', 'chart']);
242
+ });
243
+
244
+ test('truncateByWords cuts at word boundaries instead of mid-word', () => {
245
+ const thaiText = 'จวีจิ้งอีปล่อยเพลงใหม่ล่าสุดพร้อมมิวสิกวิดีโอ '.repeat(20);
246
+ const truncated = truncateByWords(thaiText, 60, 'th');
247
+ assert.ok(truncated.length <= 60);
248
+ assert.ok(thaiText.startsWith(truncated));
249
+
250
+ const english = 'word '.repeat(100);
251
+ const truncatedEnglish = truncateByWords(english, 30, 'en');
252
+ assert.ok(truncatedEnglish.length <= 30);
253
+ assert.equal(truncatedEnglish.trimEnd().split(' ').pop(), 'word');
254
+ });
255
+
256
+ test('simhashText detects near-duplicate passages across pages', () => {
257
+ const original = 'Ju Jingyi released her newest single on September 1st alongside a full music video filmed in Chongqing.';
258
+ const paraphrase = 'Ju Jingyi released her newest single on September 1st alongside a full music video filmed in Chongqing!';
259
+ const different = 'The recipe for the soup requires boiling noodles for exactly four minutes before serving them hot.';
260
+
261
+ const state = { hashes: [] };
262
+ const first = filterUniquePassages([{ index: 0, heading: '', text: original }], state);
263
+ const duplicate = filterUniquePassages([{ index: 1, heading: '', text: paraphrase }], state);
264
+ const unique = filterUniquePassages([{ index: 2, heading: '', text: different }], state);
265
+
266
+ assert.equal(first.length, 1);
267
+ assert.equal(duplicate.length, 0, 'near-identical passage must be dropped');
268
+ assert.equal(unique.length, 1);
269
+
270
+ const deduped = dedupePassages([
271
+ { index: 0, heading: '', text: original },
272
+ { index: 1, heading: '', text: paraphrase },
273
+ { index: 2, heading: '', text: different },
274
+ ]);
275
+ assert.equal(deduped.length, 2);
276
+ });
277
+
278
+ test('hamming64 and simhashText behave on identical and disjoint input', () => {
279
+ const a = simhashText('identical passage text for hashing purposes here');
280
+ const b = simhashText('identical passage text for hashing purposes here');
281
+ assert.equal(hamming64(a, b), 0);
282
+ const c = simhashText('completely unrelated content about chess strategies and endgame play');
283
+ assert.ok(hamming64(a, c) > 6);
284
+ });