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,15 +1,46 @@
1
+ import { dynamicToolRouter } from './toolRouter.js';
2
+
3
+ export function estimateTextTokens(text) {
4
+ if (!text) return 0;
5
+ const str = String(text).trim();
6
+ if (!str) return 0;
7
+ const dense = (str.match(/[\u0E00-\u0E7F\u3040-\u30FF\u3400-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF]/g) || []).length;
8
+ const ascii = str.length - dense;
9
+ const denseTokens = Math.ceil(dense * 1.25);
10
+ if (ascii === 0) return denseTokens;
11
+ const remainder = str.replace(/[\u0E00-\u0E7F\u3040-\u30FF\u3400-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF]/g, ' ');
12
+ const words = remainder.split(/\s+/).filter(Boolean).length;
13
+ return denseTokens + Math.max(Math.ceil(words * 1.3), Math.ceil(ascii / 3.5));
14
+ }
15
+
16
+ const messageTokenCache = new WeakMap();
17
+
18
+ export function estimateMessageTokens(msg) {
19
+ if (!msg || typeof msg !== 'object') return 0;
20
+ const contentKey = typeof msg.content === 'string'
21
+ ? msg.content
22
+ : (msg.content != null ? JSON.stringify(msg.content) : '');
23
+ const cached = messageTokenCache.get(msg);
24
+ if (cached && cached.contentKey === contentKey && cached.toolCalls === msg.tool_calls) {
25
+ return cached.tokens;
26
+ }
27
+ const tokens = (contentKey ? estimateTextTokens(contentKey) : 0)
28
+ + (msg.tool_calls ? estimateTextTokens(JSON.stringify(msg.tool_calls)) : 0);
29
+ messageTokenCache.set(msg, { contentKey, toolCalls: msg.tool_calls, tokens });
30
+ return tokens;
31
+ }
32
+
1
33
  export function estimateTokenCount(textOrMessages) {
2
34
  if (!textOrMessages) return 0;
3
35
  if (typeof textOrMessages === 'string') {
4
- return Math.ceil(textOrMessages.length / 3.5);
36
+ return estimateTextTokens(textOrMessages);
5
37
  }
6
38
  if (Array.isArray(textOrMessages)) {
7
- let chars = 0;
39
+ let tokens = 0;
8
40
  for (const msg of textOrMessages) {
9
- if (typeof msg.content === 'string') chars += msg.content.length;
10
- if (msg.tool_calls) chars += JSON.stringify(msg.tool_calls).length;
41
+ tokens += estimateMessageTokens(msg);
11
42
  }
12
- return Math.ceil(chars / 3.5);
43
+ return tokens;
13
44
  }
14
45
  return 0;
15
46
  }
@@ -38,42 +69,225 @@ export class ContextCompactor {
38
69
  return points.slice(-8).join(' | ');
39
70
  }
40
71
 
41
- compact(conversationHistory = [], options = {}) {
72
+
73
+ _splitWindow(conversationHistory, keepRecent) {
74
+ const systemMessage = conversationHistory[0]?.role === 'system' ? conversationHistory[0] : null;
75
+ const nonSystemMessages = systemMessage ? conversationHistory.slice(1) : conversationHistory;
76
+
77
+ let start = Math.max(0, nonSystemMessages.length - keepRecent);
78
+ while (start > 0 && nonSystemMessages[start]?.role === 'tool') start--;
79
+
80
+ while (start < nonSystemMessages.length && nonSystemMessages[start]?.role === 'tool') start++;
81
+
82
+ return {
83
+ systemMessage,
84
+ olderMessages: nonSystemMessages.slice(0, start),
85
+ recentMessages: nonSystemMessages.slice(start),
86
+ };
87
+ }
88
+
89
+ _assemble(systemMessage, olderMessages, recentMessages, summaryText) {
90
+ const text = summaryText ?? this.summarizeHistorySync(olderMessages);
91
+ const compacted = [];
92
+ if (systemMessage) compacted.push(systemMessage);
93
+ if (text) compacted.push({ role: 'system', content: `[CONVERSATION CONTEXT SUMMARY OF EARLIER MESSAGES: ${text}]` });
94
+ compacted.push(...recentMessages);
95
+ return compacted;
96
+ }
97
+
98
+ _shouldCompact(conversationHistory, options) {
42
99
  if (!Array.isArray(conversationHistory) || conversationHistory.length <= this.keepRecentMessages + 1) {
43
- return conversationHistory;
100
+ return false;
44
101
  }
45
-
46
102
  const currentTokens = estimateTokenCount(conversationHistory);
47
103
  const maxTokens = options.maxTokens || this.maxTokens;
48
104
  const keepRecent = options.keepRecentMessages || this.keepRecentMessages;
49
-
50
105
  if (currentTokens <= maxTokens && conversationHistory.length <= keepRecent + 2) {
106
+ return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ compact(conversationHistory = [], options = {}) {
112
+ if (!this._shouldCompact(conversationHistory, options)) {
51
113
  return conversationHistory;
52
114
  }
115
+ const keepRecent = options.keepRecentMessages || this.keepRecentMessages;
116
+ const { systemMessage, olderMessages, recentMessages } = this._splitWindow(conversationHistory, keepRecent);
117
+ if (olderMessages.length === 0) return conversationHistory;
118
+ return this._assemble(systemMessage, olderMessages, recentMessages);
119
+ }
53
120
 
54
- const systemMessage = conversationHistory[0]?.role === 'system' ? conversationHistory[0] : null;
55
- const nonSystemMessages = systemMessage ? conversationHistory.slice(1) : conversationHistory;
56
121
 
57
- if (nonSystemMessages.length <= keepRecent) {
122
+ async compactAsync(conversationHistory = [], options = {}) {
123
+ if (!this._shouldCompact(conversationHistory, options)) {
58
124
  return conversationHistory;
59
125
  }
126
+ const keepRecent = options.keepRecentMessages || this.keepRecentMessages;
127
+ const { systemMessage, olderMessages, recentMessages } = this._splitWindow(conversationHistory, keepRecent);
128
+ if (olderMessages.length === 0) return conversationHistory;
60
129
 
61
- const olderMessages = nonSystemMessages.slice(0, -keepRecent);
62
- const recentMessages = nonSystemMessages.slice(-keepRecent);
130
+ let llmSummary = null;
131
+ if (typeof options.summarizer === 'function') {
132
+ try {
133
+ llmSummary = String(await options.summarizer(olderMessages) || '').trim() || null;
134
+ } catch { }
135
+ }
136
+ return this._assemble(systemMessage, olderMessages, recentMessages, llmSummary ?? undefined);
137
+ }
138
+ }
63
139
 
64
- const summaryText = this.summarizeHistorySync(olderMessages);
65
- const summaryContextMessage = {
66
- role: 'system',
67
- content: `[CONVERSATION CONTEXT SUMMARY OF EARLIER MESSAGES: ${summaryText}]`
68
- };
140
+ export const contextCompactor = new ContextCompactor();
69
141
 
70
- const compacted = [];
71
- if (systemMessage) compacted.push(systemMessage);
72
- if (summaryText) compacted.push(summaryContextMessage);
73
- compacted.push(...recentMessages);
142
+ export function compactToolsForPrompt(query = '', options = {}) {
143
+ const rawQuery = String(query || '');
144
+ const router = options.router || dynamicToolRouter;
74
145
 
75
- return compacted;
146
+ let routed = router.routeToolsForQuery(rawQuery, options) || [];
147
+
148
+
149
+ if (/photoshoot|magazine|editorial|port|大片|九月刊|写真|นิตยสาร|แฟชั่นเซ็ต/i.test(rawQuery)) {
150
+
151
+ if (!/contract|ambassador|commercial|代言|พรีเซนเตอร์|ทูต/i.test(rawQuery)) {
152
+ routed = routed.filter(t => (t.function?.name || t.schema?.name || t.name) !== 'get_brand_endorsements');
153
+ }
154
+
155
+ const hasGallery = routed.some(t => (t.function?.name || t.schema?.name || t.name) === 'get_gallery_photos');
156
+ if (!hasGallery) {
157
+ const galleryTool = router.allTools?.find(t => (t.function?.name || t.schema?.name) === 'get_gallery_photos');
158
+ if (galleryTool) {
159
+ routed.unshift(galleryTool);
160
+ }
161
+ }
76
162
  }
163
+
164
+ return routed.map(t => {
165
+ if (!t.schema && t.function) return { ...t, schema: t.function };
166
+ if (!t.function && t.schema) return { ...t, function: t.schema };
167
+ return t;
168
+ });
169
+ }
170
+
171
+ const SEARCH_URL_REGEX = /(SearchUrl|SearchQuery|TrailerSearch|commercialSearch|youtubeCommercialSearch|bilibiliSearchUrl)/i;
172
+ const ESSENTIAL_WORK_KEYS = new Set([
173
+ 'id', 'title', 'titleTh', 'titleCn', 'titleEn', 'year', 'role', 'roleCn', 'duration', 'category', 'synopsis'
174
+ ]);
175
+ const ESSENTIAL_BRAND_KEYS = new Set([
176
+ 'brand', 'brandNameEn', 'brandNameCn', 'year', 'title', 'category', 'isGlobal', 'highlight', 'tier', 'salesHighlights', 'stat', 'revenue', 'detail'
177
+ ]);
178
+ const ESSENTIAL_SALES_KEYS = new Set([
179
+ 'brand', 'brandId', 'stat', 'revenue', 'detail'
180
+ ]);
181
+ const ESSENTIAL_TIMELINE_KEYS = new Set([
182
+ 'year', 'period', 'title', 'titleCn', 'era', 'description', 'highlight', 'details'
183
+ ]);
184
+ const ESSENTIAL_AWARD_KEYS = new Set([
185
+ 'year', 'award', 'title', 'ceremony', 'category', 'work', 'result'
186
+ ]);
187
+ const ESSENTIAL_VIDEO_KEYS = new Set([
188
+ 'id', 'title', 'titleTh', 'titleCn', 'titleEn', 'year', 'releaseYear', 'category', 'duration', 'youtubeId', 'work', 'dramaName'
189
+ ]);
190
+ const ESSENTIAL_SONG_KEYS = new Set([
191
+ 'id', 'title', 'titleTh', 'titleCn', 'titleEn', 'year', 'category', 'type', 'work', 'role', 'duration', 'youtubeId', 'youtubeUrl', 'isDirectLink'
192
+ ]);
193
+ const ESSENTIAL_ROLE_KEYS = new Set([
194
+ 'workTitle', 'year', 'role', 'roleCn'
195
+ ]);
196
+
197
+ function stripSearchUrls(obj) {
198
+ if (Array.isArray(obj)) {
199
+ return obj.map(stripSearchUrls);
200
+ }
201
+ if (obj && typeof obj === 'object') {
202
+ const clean = {};
203
+ for (const [k, v] of Object.entries(obj)) {
204
+ if (SEARCH_URL_REGEX.test(k)) continue;
205
+ clean[k] = stripSearchUrls(v);
206
+ }
207
+ return clean;
208
+ }
209
+ return obj;
210
+ }
211
+
212
+ export function compactToolResultForContext(result, options = {}) {
213
+ if (!result || typeof result !== 'object') return result;
214
+ const maxTokens = options.maxTokens || 800;
215
+
216
+ let stripped = stripSearchUrls(result);
217
+
218
+ const projectList = (arr, keys) => {
219
+ if (!Array.isArray(arr)) return arr;
220
+ return arr.map(item => {
221
+ if (!item || typeof item !== 'object') return item;
222
+ const p = {};
223
+ for (const k of keys) {
224
+ if (item[k] !== undefined) p[k] = item[k];
225
+ }
226
+ return p;
227
+ });
228
+ };
229
+
230
+ if (Array.isArray(stripped.works)) stripped.works = projectList(stripped.works, ESSENTIAL_WORK_KEYS);
231
+ if (Array.isArray(stripped.discography)) stripped.discography = projectList(stripped.discography, ESSENTIAL_SONG_KEYS);
232
+ if (Array.isArray(stripped.songs)) stripped.songs = projectList(stripped.songs, ESSENTIAL_SONG_KEYS);
233
+ if (Array.isArray(stripped.brands)) stripped.brands = projectList(stripped.brands, ESSENTIAL_BRAND_KEYS);
234
+ if (Array.isArray(stripped.salesHighlights)) stripped.salesHighlights = projectList(stripped.salesHighlights, ESSENTIAL_SALES_KEYS);
235
+ if (Array.isArray(stripped.timeline)) stripped.timeline = projectList(stripped.timeline, ESSENTIAL_TIMELINE_KEYS);
236
+ if (Array.isArray(stripped.milestones)) stripped.milestones = projectList(stripped.milestones, ESSENTIAL_TIMELINE_KEYS);
237
+ if (Array.isArray(stripped.eras)) stripped.eras = projectList(stripped.eras, ESSENTIAL_TIMELINE_KEYS);
238
+ if (Array.isArray(stripped.awards)) stripped.awards = projectList(stripped.awards, ESSENTIAL_AWARD_KEYS);
239
+ if (Array.isArray(stripped.videos)) stripped.videos = projectList(stripped.videos, ESSENTIAL_VIDEO_KEYS);
240
+ if (Array.isArray(stripped.musicVideos)) stripped.musicVideos = projectList(stripped.musicVideos, ESSENTIAL_VIDEO_KEYS);
241
+ if (Array.isArray(stripped.roles)) stripped.roles = projectList(stripped.roles, ESSENTIAL_ROLE_KEYS);
242
+
243
+
244
+ let serialized = JSON.stringify(stripped);
245
+ let tokenCount = estimateTextTokens(serialized);
246
+ if (tokenCount <= maxTokens) return stripped;
247
+
248
+ const truncateNode = (node) => {
249
+ if (Array.isArray(node)) {
250
+ if (node.length > 5) {
251
+ return node.slice(0, 5).map(truncateNode);
252
+ }
253
+ return node.map(truncateNode);
254
+ }
255
+ if (node && typeof node === 'object') {
256
+ const out = {};
257
+ for (const [k, v] of Object.entries(node)) {
258
+ out[k] = truncateNode(v);
259
+ }
260
+ return out;
261
+ }
262
+ if (typeof node === 'string' && node.length > 240) {
263
+ return `${node.slice(0, 240)}…`;
264
+ }
265
+ return node;
266
+ };
267
+
268
+ stripped = truncateNode(stripped);
269
+ serialized = JSON.stringify(stripped);
270
+ tokenCount = estimateTextTokens(serialized);
271
+
272
+ if (tokenCount > maxTokens) {
273
+ const aggressive = (node) => {
274
+ if (Array.isArray(node)) return node.slice(0, 3).map(aggressive);
275
+ if (node && typeof node === 'object') {
276
+ const out = {};
277
+ for (const [k, v] of Object.entries(node)) {
278
+ out[k] = aggressive(v);
279
+ }
280
+ return out;
281
+ }
282
+ if (typeof node === 'string' && node.length > 120) {
283
+ return `${node.slice(0, 120)}…`;
284
+ }
285
+ return node;
286
+ };
287
+ stripped = aggressive(stripped);
288
+ }
289
+
290
+ stripped._compactedForContext = true;
291
+ return stripped;
77
292
  }
78
293
 
79
- export const contextCompactor = new ContextCompactor();
@@ -0,0 +1,192 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { KIKU_DATA_DIR } from './appPaths.js';
5
+
6
+ export const DEFAULT_DYNAMIC_CATALOG_PATH = path.join(KIKU_DATA_DIR, 'dynamic-catalog.json');
7
+
8
+ const VALID_CATEGORIES = new Set(['brands', 'timeline', 'gallery', 'works', 'events']);
9
+
10
+ export function createEmptyCatalog() {
11
+ return {
12
+ version: 1,
13
+ updatedAt: new Date().toISOString(),
14
+ brands: [],
15
+ timeline: [],
16
+ gallery: [],
17
+ works: [],
18
+ events: []
19
+ };
20
+ }
21
+
22
+ export function loadDynamicCatalog(catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH) {
23
+ try {
24
+ if (fs.existsSync(catalogPath)) {
25
+ const raw = fs.readFileSync(catalogPath, 'utf8');
26
+ const data = JSON.parse(raw);
27
+ if (data && typeof data === 'object') {
28
+ return {
29
+ version: data.version || 1,
30
+ updatedAt: data.updatedAt || new Date().toISOString(),
31
+ brands: Array.isArray(data.brands) ? data.brands : [],
32
+ timeline: Array.isArray(data.timeline) ? data.timeline : [],
33
+ gallery: Array.isArray(data.gallery) ? data.gallery : [],
34
+ works: Array.isArray(data.works) ? data.works : [],
35
+ events: Array.isArray(data.events) ? data.events : []
36
+ };
37
+ }
38
+ }
39
+ } catch (err) {
40
+
41
+ }
42
+ return createEmptyCatalog();
43
+ }
44
+
45
+ export function saveDynamicCatalog(catalog, catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH) {
46
+ try {
47
+ const dir = path.dirname(catalogPath);
48
+ if (!fs.existsSync(dir)) {
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ }
51
+ const safeCatalog = {
52
+ version: 1,
53
+ updatedAt: new Date().toISOString(),
54
+ brands: Array.isArray(catalog?.brands) ? catalog.brands : [],
55
+ timeline: Array.isArray(catalog?.timeline) ? catalog.timeline : [],
56
+ gallery: Array.isArray(catalog?.gallery) ? catalog.gallery : [],
57
+ works: Array.isArray(catalog?.works) ? catalog.works : [],
58
+ events: Array.isArray(catalog?.events) ? catalog.events : []
59
+ };
60
+ fs.writeFileSync(catalogPath, JSON.stringify(safeCatalog, null, 2), 'utf8');
61
+ return true;
62
+ } catch (err) {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ export function validateCatalogEntry(category, entry) {
68
+ if (!VALID_CATEGORIES.has(category)) {
69
+ return { valid: false, reason: `Invalid category "${category}". Allowed: ${Array.from(VALID_CATEGORIES).join(', ')}` };
70
+ }
71
+ if (!entry || typeof entry !== 'object') {
72
+ return { valid: false, reason: 'Entry must be a non-null object' };
73
+ }
74
+
75
+
76
+ const hasTitle = Boolean(
77
+ entry.title || entry.titleCn || entry.titleEn || entry.titleTh ||
78
+ entry.brand || entry.brandNameEn || entry.brandNameCn || entry.name
79
+ );
80
+ if (!hasTitle) {
81
+ return { valid: false, reason: 'Entry must specify at least one title or brand name' };
82
+ }
83
+
84
+ return { valid: true };
85
+ }
86
+
87
+ export function addDynamicEntry(category, rawEntry, { catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH, trustTier = 1, source = 'manual' } = {}) {
88
+ const validation = validateCatalogEntry(category, rawEntry);
89
+ if (!validation.valid) {
90
+ throw new Error(validation.reason);
91
+ }
92
+
93
+ const catalog = loadDynamicCatalog(catalogPath);
94
+ const now = new Date().toISOString();
95
+ const currentYear = new Date().getFullYear();
96
+
97
+ const idPrefix = `dyn-${category.slice(0, 3)}-`;
98
+ const cleanId = rawEntry.id
99
+ ? String(rawEntry.id).trim()
100
+ : `${idPrefix}${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
101
+
102
+ const entry = {
103
+ ...rawEntry,
104
+ id: cleanId,
105
+ year: Number(rawEntry.year) || currentYear,
106
+ trustTier: Number(trustTier) || 1,
107
+ source: String(source || 'manual'),
108
+ createdAt: rawEntry.createdAt || now,
109
+ updatedAt: now
110
+ };
111
+
112
+
113
+ const list = catalog[category];
114
+ const existingIdx = list.findIndex(item => item.id === cleanId);
115
+ if (existingIdx >= 0) {
116
+ list[existingIdx] = entry;
117
+ } else {
118
+ list.unshift(entry);
119
+ }
120
+
121
+ saveDynamicCatalog(catalog, catalogPath);
122
+ return entry;
123
+ }
124
+
125
+ export function getDynamicEntries(category, filter = null, catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH) {
126
+ if (!VALID_CATEGORIES.has(category)) return [];
127
+ const catalog = loadDynamicCatalog(catalogPath);
128
+ let entries = catalog[category] || [];
129
+
130
+ if (typeof filter === 'function') {
131
+ entries = entries.filter(filter);
132
+ } else if (filter && typeof filter === 'object') {
133
+ entries = entries.filter(item => {
134
+ for (const [k, v] of Object.entries(filter)) {
135
+ if (v !== undefined && item[k] !== v) return false;
136
+ }
137
+ return true;
138
+ });
139
+ }
140
+
141
+ return entries;
142
+ }
143
+
144
+ export function clearDynamicCatalog(catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH) {
145
+ const empty = createEmptyCatalog();
146
+ saveDynamicCatalog(empty, catalogPath);
147
+ return empty;
148
+ }
149
+
150
+ export function getDynamicCatalogStats(catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH) {
151
+ const catalog = loadDynamicCatalog(catalogPath);
152
+ return {
153
+ totalEntries: (catalog.brands.length + catalog.timeline.length + catalog.gallery.length + catalog.works.length + catalog.events.length),
154
+ brandsCount: catalog.brands.length,
155
+ timelineCount: catalog.timeline.length,
156
+ galleryCount: catalog.gallery.length,
157
+ worksCount: catalog.works.length,
158
+ eventsCount: catalog.events.length,
159
+ updatedAt: catalog.updatedAt
160
+ };
161
+ }
162
+
163
+ export function pruneExpiredEntries(maxDaysOrOptions = 90, maybeCatalogPath = null) {
164
+ let maxDays = 90;
165
+ let catalogPath = DEFAULT_DYNAMIC_CATALOG_PATH;
166
+
167
+ if (typeof maxDaysOrOptions === 'object' && maxDaysOrOptions !== null) {
168
+ maxDays = maxDaysOrOptions.maxDays ?? maxDaysOrOptions.ttlDays ?? 90;
169
+ catalogPath = maxDaysOrOptions.catalogPath || DEFAULT_DYNAMIC_CATALOG_PATH;
170
+ } else {
171
+ maxDays = Number(maxDaysOrOptions) || 90;
172
+ if (maybeCatalogPath) catalogPath = maybeCatalogPath;
173
+ }
174
+
175
+ const catalog = loadDynamicCatalog(catalogPath);
176
+ const cutoff = Date.now() - (maxDays * 86400000);
177
+ let prunedCount = 0;
178
+
179
+ for (const cat of VALID_CATEGORIES) {
180
+ const before = catalog[cat].length;
181
+ catalog[cat] = catalog[cat].filter(item => {
182
+ const created = item.createdAt ? new Date(item.createdAt).getTime() : 0;
183
+ return !created || created >= cutoff;
184
+ });
185
+ prunedCount += (before - catalog[cat].length);
186
+ }
187
+
188
+ if (prunedCount > 0) {
189
+ saveDynamicCatalog(catalog, catalogPath);
190
+ }
191
+ return { prunedCount };
192
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * envBootstrap — one-shot .env loading for the kiku-agent process graph.
3
+ *
4
+ * This module MUST be imported before any module that snapshots the
5
+ * environment at evaluation time (config.js provider defaults, etc.).
6
+ * cli.js imports it first; youtubeAuth.js imports it defensively because
7
+ * it is also reached through other entry graphs (mcp stdio, web chat).
8
+ *
9
+ * Loading rules (unchanged):
10
+ * 1. package-root/.env — development checkouts
11
+ * 2. <KIKU_DATA_DIR>/.env — user-owned location (global installs)
12
+ * Both files are read; existing process.env values always win.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import { PACKAGE_ENV_PATH, USER_ENV_PATH } from './envManager.js';
17
+
18
+ let applied = false;
19
+ let appliedFiles = [];
20
+
21
+ export function bootstrapEnv({ packageEnvPath = PACKAGE_ENV_PATH, userEnvPath = USER_ENV_PATH, force = false } = {}) {
22
+ if (applied && !force) {
23
+ return { applied: true, files: [...appliedFiles] };
24
+ }
25
+ applied = true;
26
+ appliedFiles = [];
27
+
28
+ try {
29
+ for (const envFile of [packageEnvPath, userEnvPath]) {
30
+ if (!fs.existsSync(envFile)) continue;
31
+ appliedFiles.push(envFile);
32
+ const text = fs.readFileSync(envFile, 'utf8');
33
+ for (const line of text.split('\n')) {
34
+ const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
35
+ if (!m || line.trim().startsWith('#')) continue;
36
+ const key = m[1].trim();
37
+ const val = m[2].trim().replace(/^["']|["']$/g, '');
38
+ if (process.env[key] === undefined) process.env[key] = val;
39
+ }
40
+ }
41
+ } catch {}
42
+
43
+ return { applied: true, files: [...appliedFiles] };
44
+ }
45
+
46
+ bootstrapEnv();