kiku-agent 1.0.0

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 (45) hide show
  1. package/README.md +122 -0
  2. package/package.json +85 -0
  3. package/server/agent/agentCore.js +782 -0
  4. package/server/agent/agentState.js +140 -0
  5. package/server/agent/audioPlayer.js +673 -0
  6. package/server/agent/cli.js +1111 -0
  7. package/server/agent/commandRegistry.js +58 -0
  8. package/server/agent/commands.js +788 -0
  9. package/server/agent/config.js +90 -0
  10. package/server/agent/errors.js +9 -0
  11. package/server/agent/eventBus.js +103 -0
  12. package/server/agent/llmClient.js +200 -0
  13. package/server/agent/memory.js +566 -0
  14. package/server/agent/sanitizer.js +314 -0
  15. package/server/agent/session.js +239 -0
  16. package/server/agent/theme.js +124 -0
  17. package/server/agent/tools.js +1528 -0
  18. package/server/agent/ui.js +469 -0
  19. package/server/agent/webSearch.js +719 -0
  20. package/server/agent/youtubeAuth.js +299 -0
  21. package/server/mcp/claude-desktop-config.example.json +9 -0
  22. package/server/mcp/data/awards.js +14 -0
  23. package/server/mcp/data/brands.js +9 -0
  24. package/server/mcp/data/discography.js +36 -0
  25. package/server/mcp/data/gallery.js +25 -0
  26. package/server/mcp/data/index.js +7 -0
  27. package/server/mcp/data/profile.js +44 -0
  28. package/server/mcp/data/timeline.js +16 -0
  29. package/server/mcp/data/works.js +104 -0
  30. package/server/mcp/handler.js +159 -0
  31. package/server/mcp/prompts.js +143 -0
  32. package/server/mcp/resources.js +54 -0
  33. package/server/mcp/security.js +138 -0
  34. package/server/mcp/server.js +100 -0
  35. package/server/mcp/stdio.js +33 -0
  36. package/server/mcp/tools.js +126 -0
  37. package/src/data/awardsData.js +146 -0
  38. package/src/data/brandsData.js +249 -0
  39. package/src/data/discographyData.js +874 -0
  40. package/src/data/galleryData.js +666 -0
  41. package/src/data/musicVideoData.js +406 -0
  42. package/src/data/profileData.js +156 -0
  43. package/src/data/socialUpdatesData.js +381 -0
  44. package/src/data/timelineData.js +172 -0
  45. package/src/data/worksData.js +516 -0
@@ -0,0 +1,314 @@
1
+ import path from 'node:path';
2
+
3
+
4
+
5
+ const MAX_QUERY_LENGTH = 4096;
6
+ const MAX_PATH_LENGTH = 1024;
7
+ const MAX_URL_LENGTH = 2048;
8
+ const MAX_TOOL_ARGS_SIZE = 8192;
9
+
10
+
11
+ const SHELL_META_CHARS = /[;&|`$(){}[\]!#~<>\\]/;
12
+
13
+
14
+ const SHELL_COMMAND_PATTERNS = [
15
+ /\brm\s+-rf\b/i,
16
+ /\bmkfs\b/i,
17
+ /\bdd\s+if=/i,
18
+ /\bchmod\s+/i,
19
+ /\bchown\s+/i,
20
+ /\bsudo\s+/i,
21
+ /\bdoas\s+/i,
22
+ /\bexec\s*\(/i,
23
+ /\beval\s*\(/i,
24
+ /\bchild_process\b/i,
25
+ /\bspawn\s*\(/i,
26
+ /\brequire\s*\(\s*['"]child_process/i,
27
+ /\bos\.system\b/i,
28
+ /\bsubprocess\b/i,
29
+ /\bsystem\s*\(/i,
30
+ ];
31
+
32
+
33
+ const PROMPT_INJECTION_PATTERNS = [
34
+ /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|prompts|rules)/i,
35
+ /you\s+are\s+now\s+(a|an)\s+/i,
36
+ /new\s+system\s+prompt/i,
37
+ /disregard\s+(all\s+)?(previous|above)/i,
38
+ /override\s+(system|instructions)/i,
39
+ /\[SYSTEM\]\s*/i,
40
+ /<<SYS>>/i,
41
+ /<\/s>/i,
42
+ ];
43
+
44
+
45
+ const SAFE_PROTOCOLS = ['http:', 'https:'];
46
+
47
+ const BLOCKED_HOST_PATTERNS = [
48
+ /^localhost$/i,
49
+ /^127\./,
50
+ /^10\./,
51
+ /^172\.(1[6-9]|2\d|3[01])\./,
52
+ /^192\.168\./,
53
+ /^0\.0\.0\.0$/,
54
+ /^::$/,
55
+ /^::1$/,
56
+ /^0:0:0:0:0:0:0:1$/,
57
+ /^fe80:/i,
58
+ /^(?:fc|fd)[0-9a-f]{2}:/i,
59
+ /^::ffff:(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/i,
60
+ /^::ffff:(?:7f[0-9a-f]{2}|a[0-9a-f]{2}|c0a8|ac1[0-9a-f]|a9fe):/i,
61
+ /\bmetadata\.google/i,
62
+ /\b169\.254\.169\.254/,
63
+ ];
64
+
65
+
66
+ const DANGEROUS_EXTENSIONS = new Set([
67
+ '.sh', '.bash', '.zsh', '.ps1', '.cmd', '.bat', '.exe', '.msi',
68
+ '.com', '.scr', '.pif', '.vbs', '.vbe', '.js', '.jse', '.wsf',
69
+ '.wsh', '.reg',
70
+ ]);
71
+
72
+
73
+ export function stripAnsi(str) {
74
+ if (typeof str !== 'string') return '';
75
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
76
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
77
+ }
78
+
79
+
80
+ export function sanitizeQuery(query) {
81
+ const warnings = [];
82
+
83
+ if (typeof query !== 'string') {
84
+ return { sanitized: '', warnings: ['Query must be a string'] };
85
+ }
86
+
87
+ let sanitized = stripAnsi(query).trim();
88
+
89
+
90
+ if (sanitized.length > MAX_QUERY_LENGTH) {
91
+ warnings.push(`Query truncated from ${sanitized.length} to ${MAX_QUERY_LENGTH} chars`);
92
+ sanitized = sanitized.slice(0, MAX_QUERY_LENGTH);
93
+ }
94
+
95
+
96
+ for (const pattern of PROMPT_INJECTION_PATTERNS) {
97
+ if (pattern.test(sanitized)) {
98
+ warnings.push(`Possible prompt injection detected: ${pattern.source.slice(0, 40)}`);
99
+ break;
100
+ }
101
+ }
102
+
103
+
104
+ for (const pattern of SHELL_COMMAND_PATTERNS) {
105
+ if (pattern.test(sanitized)) {
106
+ warnings.push(`Suspicious shell pattern detected: ${pattern.source.slice(0, 40)}`);
107
+ break;
108
+ }
109
+ }
110
+
111
+ return { sanitized, warnings };
112
+ }
113
+
114
+
115
+ export function sanitizeFilePath(filePath, projectRoot = process.cwd(), { blockDangerousExtensions = true } = {}) {
116
+ if (typeof filePath !== 'string' || !filePath.trim()) {
117
+ return { safe: false, resolvedPath: '', reason: 'Path is empty or not a string' };
118
+ }
119
+
120
+
121
+ if (filePath.length > MAX_PATH_LENGTH) {
122
+ return { safe: false, resolvedPath: '', reason: `Path exceeds ${MAX_PATH_LENGTH} chars` };
123
+ }
124
+
125
+
126
+ const clean = stripAnsi(filePath).trim();
127
+
128
+
129
+ if (clean.includes('\0')) {
130
+ return { safe: false, resolvedPath: '', reason: 'Path contains null bytes' };
131
+ }
132
+
133
+
134
+ const resolved = path.resolve(projectRoot, clean);
135
+ const relative = path.relative(projectRoot, resolved);
136
+
137
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || path.isAbsolute(clean)) {
138
+ return { safe: false, resolvedPath: resolved, reason: 'Path escapes project root (traversal detected)' };
139
+ }
140
+
141
+ if (blockDangerousExtensions) {
142
+ const normalizedPath = resolved.replace(/[ .]+$/g, '');
143
+ const extension = path.extname(normalizedPath).toLowerCase();
144
+ if (DANGEROUS_EXTENSIONS.has(extension)) {
145
+ return { safe: false, resolvedPath: resolved, reason: `Dangerous file extension blocked: ${extension}` };
146
+ }
147
+ }
148
+
149
+ return { safe: true, resolvedPath: resolved };
150
+ }
151
+
152
+
153
+ export function sanitizeUrl(urlStr) {
154
+ if (typeof urlStr !== 'string' || !urlStr.trim()) {
155
+ return { safe: false, reason: 'URL is empty or not a string' };
156
+ }
157
+
158
+ if (urlStr.length > MAX_URL_LENGTH) {
159
+ return { safe: false, reason: `URL exceeds ${MAX_URL_LENGTH} chars` };
160
+ }
161
+
162
+ const clean = stripAnsi(urlStr).trim();
163
+
164
+ let url;
165
+ try {
166
+ url = new URL(clean);
167
+ } catch {
168
+ return { safe: false, reason: 'Invalid URL format' };
169
+ }
170
+
171
+
172
+ if (!SAFE_PROTOCOLS.includes(url.protocol)) {
173
+ return { safe: false, reason: `Unsafe protocol: ${url.protocol} (only http/https allowed)` };
174
+ }
175
+
176
+
177
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
178
+ for (const pattern of BLOCKED_HOST_PATTERNS) {
179
+ if (pattern.test(hostname)) {
180
+ return { safe: false, url, reason: `Blocked host (private/internal): ${hostname}` };
181
+ }
182
+ }
183
+
184
+ return { safe: true, url };
185
+ }
186
+
187
+
188
+ export function sanitizeToolArgs(toolName, args) {
189
+ const warnings = [];
190
+
191
+ if (!args || typeof args !== 'object') {
192
+ return { safe: true, sanitized: {}, warnings };
193
+ }
194
+
195
+
196
+ let serialized;
197
+ try {
198
+ serialized = JSON.stringify(args);
199
+ } catch {
200
+ return { safe: false, sanitized: {}, warnings: ['Args are not serializable'] };
201
+ }
202
+
203
+ if (serialized.length > MAX_TOOL_ARGS_SIZE) {
204
+ warnings.push(`Tool args truncated from ${serialized.length} to ${MAX_TOOL_ARGS_SIZE} bytes`);
205
+
206
+ const truncated = { ...args };
207
+ for (const [key, val] of Object.entries(truncated)) {
208
+ if (typeof val === 'string' && val.length > 1024) {
209
+ truncated[key] = val.slice(0, 1024) + '…';
210
+ }
211
+ }
212
+ return { safe: true, sanitized: truncated, warnings };
213
+ }
214
+
215
+
216
+ const sanitized = { ...args };
217
+
218
+ switch (toolName) {
219
+ case 'read_local_file':
220
+ case 'list_directory': {
221
+ const filePath = sanitized.path || sanitized.filePath;
222
+ if (filePath) {
223
+ const check = sanitizeFilePath(filePath, process.cwd(), {
224
+ blockDangerousExtensions: toolName === 'read_local_file',
225
+ });
226
+ if (!check.safe) {
227
+ return { safe: false, sanitized: {}, warnings: [`Path rejected: ${check.reason}`] };
228
+ }
229
+ }
230
+ break;
231
+ }
232
+
233
+ case 'fetch_webpage':
234
+ case 'fetch_json':
235
+ case 'screenshot_webpage': {
236
+ const url = sanitized.url;
237
+ if (url) {
238
+ const check = sanitizeUrl(url);
239
+ if (!check.safe) {
240
+ return { safe: false, sanitized: {}, warnings: [`URL rejected: ${check.reason}`] };
241
+ }
242
+ }
243
+ break;
244
+ }
245
+
246
+ case 'fetch_api': {
247
+
248
+ const endpoint = sanitized.endpoint;
249
+ if (endpoint && typeof endpoint === 'string') {
250
+ const clean = stripAnsi(endpoint).trim();
251
+ if (clean.includes('..') || clean.includes('://') || clean.includes('\0')) {
252
+ return { safe: false, sanitized: {}, warnings: ['Invalid API endpoint path'] };
253
+ }
254
+ sanitized.endpoint = clean;
255
+ }
256
+ break;
257
+ }
258
+
259
+ case 'play_music':
260
+ case 'add_to_queue': {
261
+
262
+ const query = sanitized.query;
263
+ if (query && typeof query === 'string') {
264
+ const clean = stripAnsi(query).trim();
265
+
266
+ if (SHELL_META_CHARS.test(clean)) {
267
+ warnings.push('Shell metacharacters stripped from music query');
268
+ sanitized.query = clean.replace(SHELL_META_CHARS, '');
269
+ }
270
+ }
271
+ break;
272
+ }
273
+
274
+ case 'web_search':
275
+ case 'web_extract': {
276
+ const query = sanitized.query;
277
+ if (query && typeof query === 'string') {
278
+ sanitized.query = stripAnsi(query).trim().slice(0, 512);
279
+ }
280
+ break;
281
+ }
282
+ }
283
+
284
+ return { safe: true, sanitized, warnings };
285
+ }
286
+
287
+
288
+ export function sanitizeResponse(response) {
289
+ if (typeof response !== 'string') return '';
290
+
291
+
292
+ const leakPatterns = [
293
+ /system\s*prompt\s*[:=]/i,
294
+ /you\s*are\s*(actually|really)\s+(a|an)\s+/i,
295
+ /my\s*instructions\s*(are|say|tell)/i,
296
+ /i\s*was\s*(told|instructed|programmed)\s+to/i,
297
+ ];
298
+
299
+ let sanitized = response;
300
+ for (const pattern of leakPatterns) {
301
+ if (pattern.test(sanitized)) {
302
+
303
+ break;
304
+ }
305
+ }
306
+
307
+ return sanitized;
308
+ }
309
+
310
+ export function isLikelySafe(input) {
311
+ if (typeof input !== 'string') return false;
312
+
313
+ return /^[a-zA-Z0-9\s\-_.,!?'"()()\u4E00-\u9FA5\u0E00-\u0E7F]+$/.test(input) && input.length < 500;
314
+ }
@@ -0,0 +1,239 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const SESSION_DIR = path.resolve(process.cwd(), '.kiku_sessions');
5
+ const SESSION_FILE = path.join(SESSION_DIR, 'current_session.json');
6
+
7
+ export class AgentSession {
8
+ constructor(options = {}) {
9
+ this.id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
10
+ this.createdAt = new Date();
11
+ this.thinkingMode = options.thinkingMode || 'hide';
12
+ this.messages = [];
13
+ this.stats = {
14
+ totalQueries: 0,
15
+ totalToolCalls: 0,
16
+ totalDurationMs: 0
17
+ };
18
+ }
19
+
20
+ setThinkingMode(mode) {
21
+ if (mode === 'show' || mode === 'hide') {
22
+ this.thinkingMode = mode;
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ toggleThinkingMode() {
29
+ this.thinkingMode = this.thinkingMode === 'show' ? 'hide' : 'show';
30
+ return this.thinkingMode;
31
+ }
32
+
33
+ createUserMessage(text) {
34
+ const msg = {
35
+ id: `msg_${Date.now()}_u`,
36
+ role: 'user',
37
+ createdAt: new Date(),
38
+ parts: [
39
+ { type: 'text', content: text }
40
+ ]
41
+ };
42
+ this.messages.push(msg);
43
+ this.stats.totalQueries++;
44
+
45
+ this.saveToDisk().catch(err => console.error('Session save failed:', err));
46
+ return msg;
47
+ }
48
+
49
+ createAssistantMessage() {
50
+ const msg = {
51
+ id: `msg_${Date.now()}_a`,
52
+ role: 'assistant',
53
+ createdAt: new Date(),
54
+ parts: []
55
+ };
56
+ this.messages.push(msg);
57
+ return msg;
58
+ }
59
+
60
+ addReasoningPart(message, reasoningText) {
61
+ let part = message.parts.find(p => p.type === 'reasoning');
62
+ if (!part) {
63
+ part = {
64
+ type: 'reasoning',
65
+ id: `rsn_${Date.now()}`,
66
+ content: '',
67
+ startedAt: Date.now(),
68
+ endedAt: null
69
+ };
70
+ message.parts.push(part);
71
+ }
72
+ part.content += reasoningText;
73
+ return part;
74
+ }
75
+
76
+ finalizeReasoningPart(message) {
77
+ const part = message.parts.find(p => p.type === 'reasoning');
78
+ if (part && !part.endedAt) {
79
+ part.endedAt = Date.now();
80
+ part.durationMs = part.endedAt - part.startedAt;
81
+ }
82
+ }
83
+
84
+ addToolPart(message, toolName, args) {
85
+ const part = {
86
+ type: 'tool',
87
+ id: `tool_${Date.now()}_${message.parts.length}`,
88
+ name: toolName,
89
+ args,
90
+ result: null,
91
+ startedAt: Date.now(),
92
+ endedAt: null,
93
+ durationMs: 0
94
+ };
95
+ message.parts.push(part);
96
+ this.stats.totalToolCalls++;
97
+ return part;
98
+ }
99
+
100
+ finalizeToolPart(message, toolId, result) {
101
+ const part = message.parts.find(p => p.id === toolId || (p.type === 'tool' && !p.endedAt));
102
+ if (part) {
103
+ part.result = result;
104
+ part.endedAt = Date.now();
105
+ part.durationMs = part.endedAt - part.startedAt;
106
+ }
107
+ return part;
108
+ }
109
+
110
+ addTextPart(message, textChunk) {
111
+ let part = message.parts.find(p => p.type === 'text');
112
+ if (!part) {
113
+ part = {
114
+ type: 'text',
115
+ content: ''
116
+ };
117
+ message.parts.push(part);
118
+ }
119
+ part.content += textChunk;
120
+ return part;
121
+ }
122
+
123
+ async saveToDisk() {
124
+ try {
125
+ await fs.mkdir(SESSION_DIR, { recursive: true });
126
+ const sessionData = {
127
+ id: this.id,
128
+ createdAt: this.createdAt,
129
+ thinkingMode: this.thinkingMode,
130
+ messages: this.messages,
131
+ stats: this.stats
132
+ };
133
+ await fs.writeFile(SESSION_FILE, JSON.stringify(sessionData, null, 2), 'utf-8');
134
+ return true;
135
+ } catch (error) {
136
+ console.error('Failed to save session:', error);
137
+ return false;
138
+ }
139
+ }
140
+
141
+ clear() {
142
+ this.messages = [];
143
+ this.stats.totalQueries = 0;
144
+ this.stats.totalToolCalls = 0;
145
+ this.stats.totalDurationMs = 0;
146
+ }
147
+
148
+ toSnapshot() {
149
+ return {
150
+ id: this.id,
151
+ createdAt: this.createdAt,
152
+ thinkingMode: this.thinkingMode,
153
+ messages: this.messages,
154
+ stats: { ...this.stats }
155
+ };
156
+ }
157
+
158
+ reset() {
159
+ this.id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
160
+ this.createdAt = new Date();
161
+ this.messages = [];
162
+ this.stats = {
163
+ totalQueries: 0,
164
+ totalToolCalls: 0,
165
+ totalDurationMs: 0
166
+ };
167
+ }
168
+
169
+ async loadFromDisk() {
170
+ try {
171
+ const data = await fs.readFile(SESSION_FILE, 'utf-8');
172
+ const sessionData = JSON.parse(data);
173
+
174
+ this.id = sessionData.id;
175
+ this.createdAt = new Date(sessionData.createdAt);
176
+ this.thinkingMode = sessionData.thinkingMode || 'hide';
177
+ this.messages = sessionData.messages || [];
178
+ this.stats = sessionData.stats || {
179
+ totalQueries: 0,
180
+ totalToolCalls: 0,
181
+ totalDurationMs: 0
182
+ };
183
+ return true;
184
+ } catch (error) {
185
+
186
+ return false;
187
+ }
188
+ }
189
+
190
+ async clearDisk() {
191
+ try {
192
+ await fs.unlink(SESSION_FILE);
193
+ return true;
194
+ } catch (error) {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ async exportToMarkdown() {
200
+ const exportDir = path.resolve(process.cwd(), 'exports');
201
+ try {
202
+ await fs.mkdir(exportDir, { recursive: true });
203
+ } catch {}
204
+
205
+ const now = new Date();
206
+ const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
207
+ const filename = `kiku-session-${timestamp}.md`;
208
+ const filePath = path.join(exportDir, filename);
209
+
210
+ let md = `# Kiku AI Agent - Session Transcript\n\n`;
211
+ md += `**Session ID:** \`${this.id}\`\n`;
212
+ md += `**Date:** ${now.toISOString().replace('T', ' ').slice(0, 19)} UTC\n`;
213
+ md += `**Total Queries:** ${this.stats.totalQueries} | **Tool Invocations:** ${this.stats.totalToolCalls}\n\n---\n\n`;
214
+
215
+ for (const msg of this.messages) {
216
+ if (msg.role === 'user') {
217
+ const textPart = msg.parts.find(p => p.type === 'text');
218
+ md += `## User\n${textPart?.content || ''}\n\n`;
219
+ } else if (msg.role === 'assistant') {
220
+ md += `## Kiku AI\n\n`;
221
+ for (const part of msg.parts) {
222
+ if (part.type === 'reasoning' && part.content) {
223
+ md += `<details><summary>Reasoning Process (${part.durationMs ? (part.durationMs/1000).toFixed(1) + 's' : 'Complete'})</summary>\n\n\`\`\`\n${part.content.trim()}\n\`\`\`\n</details>\n\n`;
224
+ } else if (part.type === 'tool') {
225
+ md += `> >> **Tool Execution: \`${part.name}\`** (${part.durationMs}ms)\n`;
226
+ md += `> - Arguments: \`${JSON.stringify(part.args)}\`\n`;
227
+ md += `> - Status: \`${part.result?.status || 'success'}\`\n\n`;
228
+ } else if (part.type === 'text') {
229
+ md += `${part.content}\n\n`;
230
+ }
231
+ }
232
+ md += `---\n\n`;
233
+ }
234
+ }
235
+
236
+ await fs.writeFile(filePath, md, 'utf-8');
237
+ return { filePath, filename };
238
+ }
239
+ }
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const THEME_STATE_DIR = path.resolve(process.cwd(), '.kiku_sessions');
5
+ const THEME_STATE_FILE = path.join(THEME_STATE_DIR, 'theme.json');
6
+
7
+ export const THEMES = Object.freeze({
8
+ sakura: Object.freeze({
9
+ label: 'Sakura',
10
+ description: 'Pink accents with neutral gray surfaces',
11
+ colors: Object.freeze({
12
+ primary: '255;110;180', secondary: '255;165;195', highlight: '255;215;80',
13
+ warning: '245;158;11', info: '56;210;255', success: '50;235;130',
14
+ tertiary: '180;140;255', muted: '130;140;160', subdued: '80;85;95',
15
+ text: '255;241;247', error: '255;82;115', panel: '45;48;54', panelHeader: '72;52;65',
16
+ }),
17
+ }),
18
+ midnight: Object.freeze({
19
+ label: 'Midnight',
20
+ description: 'Deep blue surfaces with cool accents',
21
+ colors: Object.freeze({
22
+ primary: '96;165;250', secondary: '125;211;252', highlight: '250;204;21',
23
+ warning: '251;191;36', info: '34;211;238', success: '74;222;128',
24
+ tertiary: '167;139;250', muted: '148;163;184', subdued: '71;85;105',
25
+ text: '219;234;254', error: '251;113;133', panel: '15;23;42', panelHeader: '30;41;69',
26
+ }),
27
+ }),
28
+ ocean: Object.freeze({
29
+ label: 'Ocean',
30
+ description: 'Cyan and blue accents on marine-gray surfaces',
31
+ colors: Object.freeze({
32
+ primary: '34;211;238', secondary: '103;232;249', highlight: '253;224;71',
33
+ warning: '251;191;36', info: '56;189;248', success: '45;212;191',
34
+ tertiary: '96;165;250', muted: '148;163;184', subdued: '71;85;105',
35
+ text: '207;250;254', error: '251;113;133', panel: '8;47;59', panelHeader: '14;74;89',
36
+ }),
37
+ }),
38
+ forest: Object.freeze({
39
+ label: 'Forest',
40
+ description: 'Green and teal accents on natural dark surfaces',
41
+ colors: Object.freeze({
42
+ primary: '74;222;128', secondary: '110;231;183', highlight: '250;204;21',
43
+ warning: '251;191;36', info: '45;212;191', success: '134;239;172',
44
+ tertiary: '163;230;53', muted: '148;163;153', subdued: '71;85;79',
45
+ text: '220;252;231', error: '252;129;129', panel: '20;45;34', panelHeader: '30;64;46',
46
+ }),
47
+ }),
48
+ sunset: Object.freeze({
49
+ label: 'Sunset',
50
+ description: 'Warm orange accents on charcoal-brown surfaces',
51
+ colors: Object.freeze({
52
+ primary: '251;146;60', secondary: '253;186;116', highlight: '250;204;21',
53
+ warning: '251;191;36', info: '56;189;248', success: '74;222;128',
54
+ tertiary: '244;114;182', muted: '168;151;143', subdued: '87;72;64',
55
+ text: '255;237;213', error: '253;106;106', panel: '61;35;28', panelHeader: '87;48;38',
56
+ }),
57
+ }),
58
+ monochrome: Object.freeze({
59
+ label: 'Monochrome',
60
+ description: 'Low-distraction grayscale presentation',
61
+ colors: Object.freeze({
62
+ primary: '229;231;235', secondary: '209;213;219', highlight: '255;255;255',
63
+ warning: '209;213;219', info: '229;231;235', success: '229;231;235',
64
+ tertiary: '209;213;219', muted: '156;163;175', subdued: '107;114;128',
65
+ text: '245;245;245', error: '212;212;212', panel: '31;31;31', panelHeader: '64;64;64',
66
+ }),
67
+ }),
68
+ });
69
+
70
+ export const THEME_NAMES = Object.freeze(Object.keys(THEMES));
71
+
72
+ function loadSavedTheme() {
73
+ const envTheme = process.env.KIKU_THEME?.toLowerCase();
74
+ if (envTheme && THEMES[envTheme]) return envTheme;
75
+ try {
76
+ const saved = JSON.parse(fs.readFileSync(THEME_STATE_FILE, 'utf8'));
77
+ if (typeof saved.theme === 'string' && THEMES[saved.theme]) return saved.theme;
78
+ } catch {}
79
+ return 'sakura';
80
+ }
81
+
82
+ let activeThemeName = loadSavedTheme();
83
+
84
+ export function getThemeName() {
85
+ return activeThemeName;
86
+ }
87
+
88
+ export function getTheme(name = activeThemeName) {
89
+ return THEMES[name] || THEMES.sakura;
90
+ }
91
+
92
+ export function getThemePalette(name = activeThemeName) {
93
+ return getTheme(name).colors;
94
+ }
95
+
96
+ export function setTheme(name, { persist = true } = {}) {
97
+ const normalized = String(name || '').toLowerCase();
98
+ if (!THEMES[normalized]) return null;
99
+ activeThemeName = normalized;
100
+ process.env.KIKU_THEME = normalized;
101
+
102
+ if (persist) {
103
+ fs.mkdirSync(THEME_STATE_DIR, { recursive: true });
104
+ fs.writeFileSync(THEME_STATE_FILE, JSON.stringify({ theme: normalized }, null, 2) + '\n', 'utf8');
105
+ }
106
+ return getTheme(normalized);
107
+ }
108
+
109
+ export function ansiForeground(rgb) {
110
+ return `\x1b[38;2;${rgb}m`;
111
+ }
112
+
113
+ export function ansiBackground(rgb) {
114
+ return `\x1b[48;2;${rgb}m`;
115
+ }
116
+
117
+ export function inkColor(rgb) {
118
+ return `rgb(${rgb.replaceAll(';', ',')})`;
119
+ }
120
+
121
+ export function getInkPalette(name = activeThemeName) {
122
+ const palette = getThemePalette(name);
123
+ return Object.fromEntries(Object.entries(palette).map(([key, value]) => [key, inkColor(value)]));
124
+ }