hakimi 0.1.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 (43) hide show
  1. package/README.md +71 -0
  2. package/dist/App.d.ts +5 -0
  3. package/dist/App.js +80 -0
  4. package/dist/components/HotkeyHint.d.ts +9 -0
  5. package/dist/components/HotkeyHint.js +5 -0
  6. package/dist/components/MessageLog.d.ts +12 -0
  7. package/dist/components/MessageLog.js +10 -0
  8. package/dist/components/StatusBar.d.ts +7 -0
  9. package/dist/components/StatusBar.js +5 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +19 -0
  12. package/dist/screens/ConfigScreen.d.ts +7 -0
  13. package/dist/screens/ConfigScreen.js +114 -0
  14. package/dist/screens/HomeScreen.d.ts +12 -0
  15. package/dist/screens/HomeScreen.js +27 -0
  16. package/dist/screens/LoginScreen.d.ts +7 -0
  17. package/dist/screens/LoginScreen.js +55 -0
  18. package/dist/services/chatAgent.d.ts +18 -0
  19. package/dist/services/chatAgent.js +110 -0
  20. package/dist/services/chatRouter.d.ts +39 -0
  21. package/dist/services/chatRouter.js +246 -0
  22. package/dist/services/configAgent.d.ts +18 -0
  23. package/dist/services/configAgent.js +125 -0
  24. package/dist/services/loginService.d.ts +15 -0
  25. package/dist/services/loginService.js +48 -0
  26. package/dist/services/sessionCache.d.ts +18 -0
  27. package/dist/services/sessionCache.js +69 -0
  28. package/dist/services/theAgent.d.ts +19 -0
  29. package/dist/services/theAgent.js +139 -0
  30. package/dist/tools/askUser.d.ts +30 -0
  31. package/dist/tools/askUser.js +16 -0
  32. package/dist/tools/finishConfig.d.ts +64 -0
  33. package/dist/tools/finishConfig.js +20 -0
  34. package/dist/tools/sendMessage.d.ts +25 -0
  35. package/dist/tools/sendMessage.js +15 -0
  36. package/dist/utils/config.d.ts +16 -0
  37. package/dist/utils/config.js +44 -0
  38. package/dist/utils/paths.d.ts +4 -0
  39. package/dist/utils/paths.js +6 -0
  40. package/package.json +58 -0
  41. package/patches/@koishijs+loader+4.6.10.patch +13 -0
  42. package/patches/@moonshot-ai+kimi-agent-sdk+0.0.6.patch +52 -0
  43. package/prompts/config-agent.md +76 -0
@@ -0,0 +1,246 @@
1
+ import { readHakimiConfig } from '../utils/config.js';
2
+ import { SessionCache } from './sessionCache.js';
3
+ import { TheAgent } from './theAgent.js';
4
+ export class ChatRouter {
5
+ ctx = null;
6
+ sessionCache;
7
+ options;
8
+ isRunning = false;
9
+ agentName = 'Hakimi';
10
+ retryTimeouts = new Map();
11
+ constructor(options) {
12
+ this.options = options;
13
+ this.sessionCache = new SessionCache((sessionId) => {
14
+ this.options.onSessionEnd(sessionId);
15
+ });
16
+ }
17
+ log(message) {
18
+ this.options.onLog?.(message);
19
+ }
20
+ async sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+ async sendWithRetry(fn, maxRetries) {
24
+ let lastError = null;
25
+ for (let i = 0; i < maxRetries; i++) {
26
+ try {
27
+ await fn();
28
+ return;
29
+ }
30
+ catch (error) {
31
+ lastError = error;
32
+ const delay = Math.min(3000 * (i + 1), 30000); // 3s, 6s, 9s, ... up to 30s
33
+ this.log(`Send failed (${i + 1}/${maxRetries}), retry in ${delay / 1000}s...`);
34
+ await this.sleep(delay);
35
+ }
36
+ }
37
+ this.log(`Send failed after ${maxRetries} retries: ${lastError?.message}`);
38
+ }
39
+ async start() {
40
+ const config = await readHakimiConfig();
41
+ if (!config?.adapters || config.adapters.length === 0) {
42
+ throw new Error('No adapters configured');
43
+ }
44
+ this.agentName = config.agentName || 'Hakimi';
45
+ // Dynamic import koishi to avoid Node.js v24 compatibility issues at load time
46
+ const { Context, HTTP } = await import('koishi');
47
+ this.ctx = new Context();
48
+ // Register HTTP service (required by bot adapters)
49
+ this.ctx.plugin(HTTP);
50
+ for (const adapter of config.adapters) {
51
+ await this.loadAdapter(adapter);
52
+ }
53
+ // Listen for bot status updates
54
+ this.ctx.on('bot-status-updated', (bot) => {
55
+ this.log(`Bot ${bot.selfId || 'unknown'} status: ${bot.status}`);
56
+ // Status 3 = offline, try to reconnect
57
+ if (bot.status === 3) {
58
+ this.scheduleReconnect(bot);
59
+ }
60
+ });
61
+ // Listen for errors - don't crash, just log
62
+ this.ctx.on('internal/error', (error) => {
63
+ this.log(`Error: ${error.message || error}`);
64
+ });
65
+ this.ctx.on('message', (session) => {
66
+ this.log(`Message from ${session.userId}: ${session.content}`);
67
+ this.handleMessage(session);
68
+ });
69
+ this.log('Starting Koishi context...');
70
+ // Start with retry
71
+ await this.startWithRetry();
72
+ this.isRunning = true;
73
+ }
74
+ async startWithRetry(maxRetries = Infinity) {
75
+ let retries = 0;
76
+ while (retries < maxRetries) {
77
+ try {
78
+ await this.ctx.start();
79
+ // Log created bots
80
+ const bots = this.ctx.bots || [];
81
+ this.log(`Koishi started with ${bots.length} bot(s)`);
82
+ for (const bot of bots) {
83
+ this.log(`Bot: ${bot.platform}/${bot.selfId || 'connecting...'} - ${bot.status}`);
84
+ }
85
+ return;
86
+ }
87
+ catch (error) {
88
+ retries++;
89
+ const delay = Math.min(5000 * retries, 30000); // Max 30s delay
90
+ this.log(`Start failed (${retries}), retrying in ${delay / 1000}s: ${error}`);
91
+ await this.sleep(delay);
92
+ }
93
+ }
94
+ }
95
+ scheduleReconnect(bot) {
96
+ const botKey = `${bot.platform}-${bot.selfId}`;
97
+ // Clear existing retry timeout
98
+ const existing = this.retryTimeouts.get(botKey);
99
+ if (existing) {
100
+ clearTimeout(existing);
101
+ }
102
+ // Schedule reconnect
103
+ const timeout = setTimeout(async () => {
104
+ this.retryTimeouts.delete(botKey);
105
+ this.log(`Attempting to reconnect bot ${botKey}...`);
106
+ try {
107
+ await bot.start();
108
+ this.log(`Bot ${botKey} reconnected`);
109
+ }
110
+ catch (error) {
111
+ this.log(`Reconnect failed for ${botKey}: ${error}`);
112
+ // Schedule another retry
113
+ this.scheduleReconnect(bot);
114
+ }
115
+ }, 5000);
116
+ this.retryTimeouts.set(botKey, timeout);
117
+ }
118
+ async loadAdapter(adapter) {
119
+ if (!this.ctx)
120
+ return;
121
+ this.log(`Loading ${adapter.type} adapter...`);
122
+ switch (adapter.type) {
123
+ case 'telegram': {
124
+ const mod = await import('@koishijs/plugin-adapter-telegram');
125
+ const TelegramBot = mod.TelegramBot || mod.default;
126
+ this.ctx.plugin(TelegramBot, adapter.config);
127
+ this.log(`Telegram adapter loaded`);
128
+ break;
129
+ }
130
+ case 'slack': {
131
+ const mod = await import('@koishijs/plugin-adapter-slack');
132
+ const SlackBot = mod.SlackBot || mod.default;
133
+ this.ctx.plugin(SlackBot, adapter.config);
134
+ this.log(`Slack adapter loaded`);
135
+ break;
136
+ }
137
+ case 'feishu': {
138
+ const mod = await import('@koishijs/plugin-adapter-lark');
139
+ const LarkBot = mod.LarkBot || mod.default;
140
+ this.ctx.plugin(LarkBot, adapter.config);
141
+ this.log(`Feishu adapter loaded`);
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ handleMessage(koishiSession) {
147
+ // Only handle private messages
148
+ if (koishiSession.guildId)
149
+ return;
150
+ const sessionId = `${koishiSession.platform}-${koishiSession.selfId}-${koishiSession.userId}`;
151
+ const content = koishiSession.content || '';
152
+ let chatSession = this.sessionCache.get(sessionId);
153
+ if (!chatSession) {
154
+ chatSession = {
155
+ sessionId,
156
+ platform: koishiSession.platform,
157
+ userId: koishiSession.userId || '',
158
+ botId: koishiSession.selfId || '',
159
+ isProcessing: false,
160
+ sendFn: async (message) => {
161
+ await this.sendWithRetry(() => koishiSession.send(message), 10);
162
+ },
163
+ agent: null,
164
+ pendingMessage: null,
165
+ };
166
+ this.sessionCache.set(sessionId, chatSession);
167
+ this.options.onSessionStart(chatSession);
168
+ }
169
+ this.options.onMessage(sessionId, content);
170
+ // Process message with agent
171
+ this.processMessage(chatSession, content);
172
+ }
173
+ async processMessage(session, content) {
174
+ // If already processing, interrupt and queue the new message
175
+ if (session.isProcessing) {
176
+ this.log(`Interrupting current turn for ${session.sessionId}`);
177
+ session.pendingMessage = content;
178
+ if (session.agent) {
179
+ await session.agent.interrupt();
180
+ }
181
+ return;
182
+ }
183
+ session.isProcessing = true;
184
+ try {
185
+ // Create agent if not exists
186
+ if (!session.agent) {
187
+ this.log(`Creating agent for ${session.sessionId}`);
188
+ session.agent = new TheAgent(session.sessionId, this.agentName, {
189
+ onSend: async (message) => {
190
+ await session.sendFn(message);
191
+ },
192
+ onLog: (msg) => this.log(msg),
193
+ });
194
+ await session.agent.start();
195
+ }
196
+ // Process the message
197
+ await session.agent.sendMessage(content);
198
+ // Check for pending messages (new message came in while processing)
199
+ while (session.pendingMessage) {
200
+ const pending = session.pendingMessage;
201
+ session.pendingMessage = null;
202
+ this.log(`Processing pending message: ${pending}`);
203
+ await session.agent.sendMessage(pending);
204
+ }
205
+ }
206
+ catch (error) {
207
+ this.log(`Error processing message: ${error}`);
208
+ }
209
+ finally {
210
+ session.isProcessing = false;
211
+ }
212
+ }
213
+ getSession(sessionId) {
214
+ return this.sessionCache.get(sessionId);
215
+ }
216
+ async stop() {
217
+ // Clear all retry timeouts
218
+ for (const timeout of this.retryTimeouts.values()) {
219
+ clearTimeout(timeout);
220
+ }
221
+ this.retryTimeouts.clear();
222
+ // Close all agents
223
+ for (const session of this.sessionCache.values()) {
224
+ if (session.agent) {
225
+ await session.agent.close();
226
+ }
227
+ }
228
+ this.sessionCache.clear();
229
+ if (this.ctx) {
230
+ try {
231
+ await this.ctx.stop();
232
+ }
233
+ catch {
234
+ // Ignore stop errors
235
+ }
236
+ this.ctx = null;
237
+ }
238
+ this.isRunning = false;
239
+ }
240
+ get running() {
241
+ return this.isRunning;
242
+ }
243
+ get activeSessions() {
244
+ return this.sessionCache.size;
245
+ }
246
+ }
@@ -0,0 +1,18 @@
1
+ export interface ConfigAgentCallbacks {
2
+ onText: (text: string) => void;
3
+ onAskUser: (question: string) => Promise<string>;
4
+ onFinished: () => void;
5
+ onError: (error: Error) => void;
6
+ }
7
+ export declare function loadConfigPrompt(): Promise<string>;
8
+ export declare class ConfigAgent {
9
+ private session;
10
+ private currentTurn;
11
+ private callbacks;
12
+ private systemPrompt;
13
+ constructor(callbacks: ConfigAgentCallbacks);
14
+ start(): Promise<void>;
15
+ sendMessage(content: string): Promise<void>;
16
+ private handleEvent;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,125 @@
1
+ import { readFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createSession, createExternalTool } from '@moonshot-ai/kimi-agent-sdk';
6
+ import { z } from 'zod';
7
+ import { writeHakimiConfig } from '../utils/config.js';
8
+ import { HAKIMI_DIR } from '../utils/paths.js';
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const CONFIG_SESSION_ID = 'hakimi-config-wizard';
11
+ export async function loadConfigPrompt() {
12
+ const promptPath = join(__dirname, '../../prompts/config-agent.md');
13
+ return readFile(promptPath, 'utf-8');
14
+ }
15
+ export class ConfigAgent {
16
+ session = null;
17
+ currentTurn = null;
18
+ callbacks;
19
+ systemPrompt = '';
20
+ constructor(callbacks) {
21
+ this.callbacks = callbacks;
22
+ }
23
+ async start() {
24
+ this.systemPrompt = await loadConfigPrompt();
25
+ // Ensure work directory exists
26
+ if (!existsSync(HAKIMI_DIR)) {
27
+ await mkdir(HAKIMI_DIR, { recursive: true });
28
+ }
29
+ const askUserTool = createExternalTool({
30
+ name: 'AskUser',
31
+ description: 'Ask user for input like API tokens or configuration values',
32
+ parameters: z.object({
33
+ question: z.string().describe('The question to ask the user'),
34
+ }),
35
+ handler: async (params) => {
36
+ const answer = await this.callbacks.onAskUser(params.question);
37
+ return { output: answer, message: '' };
38
+ },
39
+ });
40
+ const finishConfigTool = createExternalTool({
41
+ name: 'FinishConfig',
42
+ description: 'Save configuration and finish the wizard',
43
+ parameters: z.object({
44
+ agentName: z.string().optional().describe('Name for the AI assistant (default: Hakimi)'),
45
+ adapters: z.array(z.object({
46
+ type: z.enum(['telegram', 'slack', 'feishu']),
47
+ config: z.record(z.any()),
48
+ })).describe('List of adapter configurations to save'),
49
+ }),
50
+ handler: async (params) => {
51
+ await writeHakimiConfig({
52
+ agentName: params.agentName || 'Hakimi',
53
+ adapters: params.adapters,
54
+ });
55
+ this.callbacks.onFinished();
56
+ return { output: `Saved configuration: agent "${params.agentName || 'Hakimi'}" with ${params.adapters.length} adapter(s)`, message: '' };
57
+ },
58
+ });
59
+ this.session = createSession({
60
+ workDir: HAKIMI_DIR,
61
+ sessionId: CONFIG_SESSION_ID,
62
+ thinking: false,
63
+ yoloMode: true,
64
+ externalTools: [askUserTool, finishConfigTool],
65
+ });
66
+ // Clear previous session context
67
+ await this.sendMessage('/clear');
68
+ // Send initial prompt with system instructions
69
+ await this.sendMessage(this.systemPrompt + '\n\nPlease start by asking the user what they want to name their AI assistant.');
70
+ }
71
+ async sendMessage(content) {
72
+ if (!this.session) {
73
+ throw new Error('Session not started');
74
+ }
75
+ const turn = this.session.prompt(content);
76
+ this.currentTurn = turn;
77
+ try {
78
+ for await (const event of turn) {
79
+ this.handleEvent(event);
80
+ }
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && error.message.includes('interrupted')) {
84
+ return;
85
+ }
86
+ this.callbacks.onError(error instanceof Error ? error : new Error(String(error)));
87
+ }
88
+ finally {
89
+ // Clear turn reference after completion
90
+ if (this.currentTurn === turn) {
91
+ this.currentTurn = null;
92
+ }
93
+ }
94
+ }
95
+ handleEvent(event) {
96
+ switch (event.type) {
97
+ case 'ContentPart':
98
+ if (event.payload.type === 'text') {
99
+ this.callbacks.onText(event.payload.text);
100
+ }
101
+ break;
102
+ case 'ApprovalRequest':
103
+ // Auto-approve any pending requests
104
+ if (this.currentTurn) {
105
+ this.currentTurn.approve(event.payload.id, 'approve').catch(() => { });
106
+ }
107
+ break;
108
+ }
109
+ }
110
+ async close() {
111
+ if (this.currentTurn) {
112
+ try {
113
+ await this.currentTurn.interrupt();
114
+ }
115
+ catch {
116
+ // Ignore interrupt errors on close
117
+ }
118
+ this.currentTurn = null;
119
+ }
120
+ if (this.session) {
121
+ await this.session.close();
122
+ this.session = null;
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,15 @@
1
+ import { EventEmitter } from 'node:events';
2
+ export interface LoginEvent {
3
+ type: 'info' | 'verification_url' | 'waiting' | 'success' | 'error';
4
+ message: string;
5
+ data?: {
6
+ verification_url?: string;
7
+ user_code?: string;
8
+ };
9
+ }
10
+ export interface LoginEmitter extends EventEmitter {
11
+ on(event: 'event', listener: (data: LoginEvent) => void): this;
12
+ on(event: 'error', listener: (error: Error) => void): this;
13
+ on(event: 'close', listener: () => void): this;
14
+ }
15
+ export declare function startLogin(): LoginEmitter;
@@ -0,0 +1,48 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ export function startLogin() {
4
+ const emitter = new EventEmitter();
5
+ const proc = spawn('kimi', ['login', '--json'], {
6
+ stdio: ['ignore', 'pipe', 'pipe'],
7
+ });
8
+ let buffer = '';
9
+ proc.stdout.on('data', (chunk) => {
10
+ buffer += chunk.toString();
11
+ const lines = buffer.split('\n');
12
+ buffer = lines.pop() || '';
13
+ for (const line of lines) {
14
+ if (!line.trim())
15
+ continue;
16
+ try {
17
+ const event = JSON.parse(line);
18
+ emitter.emit('event', event);
19
+ }
20
+ catch {
21
+ // Ignore non-JSON lines
22
+ }
23
+ }
24
+ });
25
+ proc.stderr.on('data', (chunk) => {
26
+ const message = chunk.toString().trim();
27
+ if (message) {
28
+ emitter.emit('event', { type: 'error', message });
29
+ }
30
+ });
31
+ proc.on('error', (err) => {
32
+ emitter.emit('error', err);
33
+ });
34
+ proc.on('close', () => {
35
+ // Process remaining buffer
36
+ if (buffer.trim()) {
37
+ try {
38
+ const event = JSON.parse(buffer);
39
+ emitter.emit('event', event);
40
+ }
41
+ catch {
42
+ // Ignore
43
+ }
44
+ }
45
+ emitter.emit('close');
46
+ });
47
+ return emitter;
48
+ }
@@ -0,0 +1,18 @@
1
+ export interface CachedSession<T> {
2
+ session: T;
3
+ lastActivity: number;
4
+ timer: ReturnType<typeof setTimeout>;
5
+ }
6
+ export declare class SessionCache<T> {
7
+ private sessions;
8
+ private onExpire;
9
+ constructor(onExpire: (sessionId: string, session: T) => void);
10
+ get(sessionId: string): T | undefined;
11
+ set(sessionId: string, session: T): void;
12
+ touch(sessionId: string): void;
13
+ private expire;
14
+ delete(sessionId: string): T | undefined;
15
+ clear(): void;
16
+ get size(): number;
17
+ values(): T[];
18
+ }
@@ -0,0 +1,69 @@
1
+ const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
2
+ export class SessionCache {
3
+ sessions = new Map();
4
+ onExpire;
5
+ constructor(onExpire) {
6
+ this.onExpire = onExpire;
7
+ }
8
+ get(sessionId) {
9
+ const cached = this.sessions.get(sessionId);
10
+ if (cached) {
11
+ this.touch(sessionId);
12
+ return cached.session;
13
+ }
14
+ return undefined;
15
+ }
16
+ set(sessionId, session) {
17
+ const existing = this.sessions.get(sessionId);
18
+ if (existing) {
19
+ clearTimeout(existing.timer);
20
+ }
21
+ const timer = setTimeout(() => {
22
+ this.expire(sessionId);
23
+ }, SESSION_TTL_MS);
24
+ this.sessions.set(sessionId, {
25
+ session,
26
+ lastActivity: Date.now(),
27
+ timer,
28
+ });
29
+ }
30
+ touch(sessionId) {
31
+ const cached = this.sessions.get(sessionId);
32
+ if (cached) {
33
+ clearTimeout(cached.timer);
34
+ cached.lastActivity = Date.now();
35
+ cached.timer = setTimeout(() => {
36
+ this.expire(sessionId);
37
+ }, SESSION_TTL_MS);
38
+ }
39
+ }
40
+ expire(sessionId) {
41
+ const cached = this.sessions.get(sessionId);
42
+ if (cached) {
43
+ this.onExpire(sessionId, cached.session);
44
+ this.sessions.delete(sessionId);
45
+ }
46
+ }
47
+ delete(sessionId) {
48
+ const cached = this.sessions.get(sessionId);
49
+ if (cached) {
50
+ clearTimeout(cached.timer);
51
+ this.sessions.delete(sessionId);
52
+ return cached.session;
53
+ }
54
+ return undefined;
55
+ }
56
+ clear() {
57
+ for (const [sessionId, cached] of this.sessions) {
58
+ clearTimeout(cached.timer);
59
+ this.onExpire(sessionId, cached.session);
60
+ }
61
+ this.sessions.clear();
62
+ }
63
+ get size() {
64
+ return this.sessions.size;
65
+ }
66
+ values() {
67
+ return Array.from(this.sessions.values()).map((cached) => cached.session);
68
+ }
69
+ }
@@ -0,0 +1,19 @@
1
+ export interface TheAgentCallbacks {
2
+ onSend: (message: string) => Promise<void>;
3
+ onLog?: (message: string) => void;
4
+ }
5
+ export declare class TheAgent {
6
+ private session;
7
+ private currentTurn;
8
+ private callbacks;
9
+ private sessionId;
10
+ private agentName;
11
+ private didSendMessage;
12
+ constructor(sessionId: string, agentName: string, callbacks: TheAgentCallbacks);
13
+ private log;
14
+ start(): Promise<void>;
15
+ sendMessage(content: string): Promise<void>;
16
+ private runPrompt;
17
+ interrupt(): Promise<void>;
18
+ close(): Promise<void>;
19
+ }