@kernel.chat/kbot 3.71.0 → 3.73.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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * mobile-mcp-client.ts — kbot <-> mobile-mcp integration
3
+ *
4
+ * Singleton client that manages the mobile-mcp server process lifecycle.
5
+ * Communicates via MCP protocol over stdio transport.
6
+ * Auto-installs @mobilenext/mobile-mcp via npm if not present.
7
+ *
8
+ * mobile-mcp provides native accessibility-tree-based automation for
9
+ * iOS and Android devices connected via USB or WiFi.
10
+ *
11
+ * @see https://github.com/mobile-next/mobile-mcp
12
+ */
13
+ import { spawn, execSync } from 'node:child_process';
14
+ import { Buffer } from 'node:buffer';
15
+ function encodeJsonRpc(msg) {
16
+ const body = JSON.stringify(msg);
17
+ return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
18
+ }
19
+ // ── MobileMCPClient ────────────────────────────────────────────────────
20
+ export class MobileMCPClient {
21
+ static instance = null;
22
+ process = null;
23
+ messageId = 0;
24
+ pending = new Map();
25
+ buffer = '';
26
+ initialized = false;
27
+ activeDeviceId = null;
28
+ static getInstance() {
29
+ if (!MobileMCPClient.instance) {
30
+ MobileMCPClient.instance = new MobileMCPClient();
31
+ }
32
+ return MobileMCPClient.instance;
33
+ }
34
+ /** Whether the MCP server process is running and initialized */
35
+ get isConnected() {
36
+ return this.initialized && this.process !== null && !this.process.killed;
37
+ }
38
+ /** The device ID currently being controlled */
39
+ get currentDeviceId() {
40
+ return this.activeDeviceId;
41
+ }
42
+ // ── Process lifecycle ──────────────────────────────────────────────
43
+ /** Start the mobile-mcp server process and perform MCP handshake */
44
+ async start() {
45
+ if (this.isConnected)
46
+ return;
47
+ // Ensure npx is available
48
+ try {
49
+ execSync('which npx', { stdio: 'pipe' });
50
+ }
51
+ catch {
52
+ throw new Error('npx not found. Ensure Node.js >= 22 is installed.');
53
+ }
54
+ // Spawn the mobile-mcp server via npx (auto-installs if needed)
55
+ this.process = spawn('npx', ['-y', '@mobilenext/mobile-mcp@latest'], {
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ env: { ...process.env },
58
+ });
59
+ this.buffer = '';
60
+ this.messageId = 0;
61
+ this.pending.clear();
62
+ this.process.stdout?.on('data', (chunk) => {
63
+ this.buffer += chunk.toString();
64
+ this.parseMessages();
65
+ });
66
+ // Log stderr for debugging but don't crash
67
+ this.process.stderr?.on('data', (chunk) => {
68
+ const msg = chunk.toString().trim();
69
+ if (msg && process.env.KBOT_DEBUG) {
70
+ console.error(`[mobile-mcp stderr] ${msg}`);
71
+ }
72
+ });
73
+ this.process.on('error', (err) => {
74
+ this.initialized = false;
75
+ this.process = null;
76
+ if (process.env.KBOT_DEBUG) {
77
+ console.error(`[mobile-mcp] Process error: ${err.message}`);
78
+ }
79
+ });
80
+ this.process.on('exit', (code) => {
81
+ this.initialized = false;
82
+ this.process = null;
83
+ // Reject any pending requests
84
+ const pendingEntries = Array.from(this.pending.entries());
85
+ for (const [id, { reject }] of pendingEntries) {
86
+ reject(new Error(`mobile-mcp process exited with code ${code}`));
87
+ this.pending.delete(id);
88
+ }
89
+ });
90
+ // MCP initialize handshake
91
+ try {
92
+ await this.sendRequest('initialize', {
93
+ protocolVersion: '2024-11-05',
94
+ capabilities: {},
95
+ clientInfo: { name: 'kbot', version: '3.61.0' },
96
+ });
97
+ this.sendNotification('initialized', {});
98
+ this.initialized = true;
99
+ }
100
+ catch (err) {
101
+ this.stop();
102
+ throw new Error(`mobile-mcp handshake failed: ${err instanceof Error ? err.message : String(err)}\n` +
103
+ 'Ensure @mobilenext/mobile-mcp is installed: npm install -g @mobilenext/mobile-mcp');
104
+ }
105
+ }
106
+ /** Stop the mobile-mcp server process */
107
+ stop() {
108
+ if (this.process) {
109
+ try {
110
+ // Graceful shutdown
111
+ this.sendNotification('exit', null);
112
+ }
113
+ catch { /* best effort */ }
114
+ this.process.kill();
115
+ this.process = null;
116
+ }
117
+ this.initialized = false;
118
+ this.activeDeviceId = null;
119
+ this.buffer = '';
120
+ this.pending.clear();
121
+ }
122
+ // ── MCP protocol ───────────────────────────────────────────────────
123
+ parseMessages() {
124
+ while (true) {
125
+ const headerEnd = this.buffer.indexOf('\r\n\r\n');
126
+ if (headerEnd === -1)
127
+ break;
128
+ const header = this.buffer.slice(0, headerEnd);
129
+ const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
130
+ if (!lengthMatch) {
131
+ this.buffer = this.buffer.slice(headerEnd + 4);
132
+ continue;
133
+ }
134
+ const contentLength = parseInt(lengthMatch[1], 10);
135
+ const bodyStart = headerEnd + 4;
136
+ if (this.buffer.length < bodyStart + contentLength)
137
+ break;
138
+ const body = this.buffer.slice(bodyStart, bodyStart + contentLength);
139
+ this.buffer = this.buffer.slice(bodyStart + contentLength);
140
+ try {
141
+ const msg = JSON.parse(body);
142
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
143
+ const { resolve, reject } = this.pending.get(msg.id);
144
+ this.pending.delete(msg.id);
145
+ if (msg.error) {
146
+ reject(new Error(msg.error.message));
147
+ }
148
+ else {
149
+ resolve(msg.result);
150
+ }
151
+ }
152
+ }
153
+ catch {
154
+ // Skip malformed messages
155
+ }
156
+ }
157
+ }
158
+ sendRequest(method, params, timeout = 30_000) {
159
+ return new Promise((resolve, reject) => {
160
+ if (!this.process?.stdin?.writable) {
161
+ reject(new Error('mobile-mcp process is not running. Call mobile_connect first.'));
162
+ return;
163
+ }
164
+ const id = ++this.messageId;
165
+ this.pending.set(id, { resolve, reject });
166
+ const msg = { jsonrpc: '2.0', id, method, params };
167
+ this.process.stdin.write(encodeJsonRpc(msg));
168
+ setTimeout(() => {
169
+ if (this.pending.has(id)) {
170
+ this.pending.delete(id);
171
+ reject(new Error(`mobile-mcp request timeout after ${timeout / 1000}s: ${method}`));
172
+ }
173
+ }, timeout);
174
+ });
175
+ }
176
+ sendNotification(method, params) {
177
+ if (!this.process?.stdin?.writable)
178
+ return;
179
+ const msg = { jsonrpc: '2.0', method, params };
180
+ this.process.stdin.write(encodeJsonRpc(msg));
181
+ }
182
+ /** Call a tool on the mobile-mcp server */
183
+ async callTool(toolName, args) {
184
+ if (!this.isConnected) {
185
+ throw new Error('Not connected to mobile-mcp. Call mobile_connect first.');
186
+ }
187
+ const result = await this.sendRequest('tools/call', {
188
+ name: toolName,
189
+ arguments: args,
190
+ }, 60_000);
191
+ return result;
192
+ }
193
+ /** Extract text content from an MCP tool result */
194
+ extractText(result) {
195
+ const r = result;
196
+ if (r?.content) {
197
+ return r.content
198
+ .filter(c => c.type === 'text' && c.text)
199
+ .map(c => c.text)
200
+ .join('\n');
201
+ }
202
+ return JSON.stringify(result, null, 2);
203
+ }
204
+ /** Extract image content (base64) from an MCP tool result */
205
+ extractImage(result) {
206
+ const r = result;
207
+ if (r?.content) {
208
+ const img = r.content.find(c => c.type === 'image' && c.data);
209
+ if (img)
210
+ return { data: img.data, mimeType: img.mimeType || 'image/png' };
211
+ }
212
+ return null;
213
+ }
214
+ // ── High-level device operations ───────────────────────────────────
215
+ /** List all available devices */
216
+ async listDevices() {
217
+ const result = await this.callTool('mobile_list_available_devices', {});
218
+ const text = this.extractText(result);
219
+ try {
220
+ return JSON.parse(text);
221
+ }
222
+ catch {
223
+ // Try to parse from structured output
224
+ return [];
225
+ }
226
+ }
227
+ /** Set the active device for subsequent operations */
228
+ setActiveDevice(deviceId) {
229
+ this.activeDeviceId = deviceId;
230
+ }
231
+ /** Get the active device ID, throwing if none set */
232
+ requireDevice(deviceId) {
233
+ const id = deviceId || this.activeDeviceId;
234
+ if (!id) {
235
+ throw new Error('No device selected. Use mobile_connect to connect to a device, or pass a device ID.');
236
+ }
237
+ return id;
238
+ }
239
+ /** List apps on the active device */
240
+ async listApps(deviceId) {
241
+ const device = this.requireDevice(deviceId);
242
+ const result = await this.callTool('mobile_list_apps', { device });
243
+ return this.extractText(result);
244
+ }
245
+ /** Launch an app by bundle ID */
246
+ async launchApp(packageName, deviceId) {
247
+ const device = this.requireDevice(deviceId);
248
+ const result = await this.callTool('mobile_launch_app', { device, packageName });
249
+ return this.extractText(result);
250
+ }
251
+ /** Take a screenshot, returns base64 image data */
252
+ async takeScreenshot(deviceId) {
253
+ const device = this.requireDevice(deviceId);
254
+ const result = await this.callTool('mobile_take_screenshot', { device });
255
+ const img = this.extractImage(result);
256
+ if (img)
257
+ return img;
258
+ return this.extractText(result);
259
+ }
260
+ /** Save screenshot to a file */
261
+ async saveScreenshot(saveTo, deviceId) {
262
+ const device = this.requireDevice(deviceId);
263
+ const result = await this.callTool('mobile_save_screenshot', { device, saveTo });
264
+ return this.extractText(result);
265
+ }
266
+ /** List UI elements on screen via accessibility tree */
267
+ async listElements(deviceId) {
268
+ const device = this.requireDevice(deviceId);
269
+ const result = await this.callTool('mobile_list_elements_on_screen', { device });
270
+ return this.extractText(result);
271
+ }
272
+ /** Tap at coordinates */
273
+ async tap(x, y, deviceId) {
274
+ const device = this.requireDevice(deviceId);
275
+ const result = await this.callTool('mobile_click_on_screen_at_coordinates', { device, x, y });
276
+ return this.extractText(result);
277
+ }
278
+ /** Swipe on screen */
279
+ async swipe(direction, opts) {
280
+ const device = this.requireDevice(opts?.deviceId);
281
+ const args = { device, direction };
282
+ if (opts?.x !== undefined)
283
+ args.x = opts.x;
284
+ if (opts?.y !== undefined)
285
+ args.y = opts.y;
286
+ if (opts?.distance !== undefined)
287
+ args.distance = opts.distance;
288
+ const result = await this.callTool('mobile_swipe_on_screen', args);
289
+ return this.extractText(result);
290
+ }
291
+ /** Type text */
292
+ async typeText(text, submit = false, deviceId) {
293
+ const device = this.requireDevice(deviceId);
294
+ const result = await this.callTool('mobile_type_keys', { device, text, submit });
295
+ return this.extractText(result);
296
+ }
297
+ /** Press a device button */
298
+ async pressButton(button, deviceId) {
299
+ const device = this.requireDevice(deviceId);
300
+ const result = await this.callTool('mobile_press_button', { device, button });
301
+ return this.extractText(result);
302
+ }
303
+ /** Get screen size */
304
+ async getScreenSize(deviceId) {
305
+ const device = this.requireDevice(deviceId);
306
+ const result = await this.callTool('mobile_get_screen_size', { device });
307
+ return this.extractText(result);
308
+ }
309
+ /** Open a URL in the device browser */
310
+ async openUrl(url, deviceId) {
311
+ const device = this.requireDevice(deviceId);
312
+ const result = await this.callTool('mobile_open_url', { device, url });
313
+ return this.extractText(result);
314
+ }
315
+ /** Get device orientation */
316
+ async getOrientation(deviceId) {
317
+ const device = this.requireDevice(deviceId);
318
+ const result = await this.callTool('mobile_get_orientation', { device });
319
+ return this.extractText(result);
320
+ }
321
+ /** Terminate an app */
322
+ async terminateApp(packageName, deviceId) {
323
+ const device = this.requireDevice(deviceId);
324
+ const result = await this.callTool('mobile_terminate_app', { device, packageName });
325
+ return this.extractText(result);
326
+ }
327
+ /** Double tap at coordinates */
328
+ async doubleTap(x, y, deviceId) {
329
+ const device = this.requireDevice(deviceId);
330
+ const result = await this.callTool('mobile_double_tap_on_screen', { device, x, y });
331
+ return this.extractText(result);
332
+ }
333
+ /** Long press at coordinates */
334
+ async longPress(x, y, duration, deviceId) {
335
+ const device = this.requireDevice(deviceId);
336
+ const args = { device, x, y };
337
+ if (duration !== undefined)
338
+ args.duration = duration;
339
+ const result = await this.callTool('mobile_long_press_on_screen_at_coordinates', args);
340
+ return this.extractText(result);
341
+ }
342
+ }
343
+ //# sourceMappingURL=mobile-mcp-client.js.map
package/dist/serve.d.ts CHANGED
@@ -2,6 +2,9 @@ interface ServeOptions {
2
2
  port: number;
3
3
  token?: string;
4
4
  computerUse?: boolean;
5
+ https?: boolean;
6
+ cert?: string;
7
+ key?: string;
5
8
  }
6
9
  export declare function startServe(options: ServeOptions): Promise<void>;
7
10
  export {};
package/dist/serve.js CHANGED
@@ -1,9 +1,11 @@
1
- // kbot Serve — HTTP server that exposes all tools over REST
1
+ // kbot Serve — HTTP/HTTPS server that exposes all tools over REST
2
2
  //
3
3
  // Usage:
4
4
  // kbot serve # Start on default port 7437
5
5
  // kbot serve --port 3000 # Custom port
6
6
  // kbot serve --token mysecret # Require auth token
7
+ // kbot serve --https # HTTPS with auto-generated self-signed cert
8
+ // kbot serve --cert x.pem --key x.key # HTTPS with custom cert
7
9
  //
8
10
  // Endpoints:
9
11
  // GET /health — Health check
@@ -13,6 +15,7 @@
13
15
  // POST /stream — SSE streaming agent execution
14
16
  // GET /metrics — Tool execution metrics
15
17
  import { createServer } from 'node:http';
18
+ import { createServer as createHttpsServer } from 'node:https';
16
19
  import { createRequire } from 'node:module';
17
20
  import { registerAllTools, getAllTools, executeTool, getToolDefinitionsForApi, getToolMetrics } from './tools/index.js';
18
21
  import { extractMcpAppFromText, renderMcpApp, listAppCapableTools } from './mcp-apps.js';
@@ -22,6 +25,10 @@ import { runAgent } from './agent.js';
22
25
  import { destroySession } from './memory.js';
23
26
  import { randomUUID } from 'node:crypto';
24
27
  import { mountA2ARoutes } from './a2a.js';
28
+ import { execSync } from 'node:child_process';
29
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { homedir } from 'node:os';
25
32
  const __require = createRequire(import.meta.url);
26
33
  const VERSION = __require('../package.json').version;
27
34
  function cors(res) {
@@ -42,13 +49,42 @@ function readBody(req) {
42
49
  req.on('error', reject);
43
50
  });
44
51
  }
52
+ /** Generate or load a self-signed TLS certificate for localhost */
53
+ function ensureSelfSignedCert() {
54
+ const certDir = join(homedir(), '.kbot', 'certs');
55
+ const certPath = join(certDir, 'localhost.crt');
56
+ const keyPath = join(certDir, 'localhost.key');
57
+ if (existsSync(certPath) && existsSync(keyPath)) {
58
+ return { cert: readFileSync(certPath, 'utf-8'), key: readFileSync(keyPath, 'utf-8') };
59
+ }
60
+ mkdirSync(certDir, { recursive: true });
61
+ printInfo('Generating self-signed TLS certificate for localhost...');
62
+ execSync(`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
63
+ `-nodes -days 365 -subj "/CN=localhost" ` +
64
+ `-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" ` +
65
+ `-keyout "${keyPath}" -out "${certPath}"`, { stdio: 'pipe' });
66
+ printSuccess('Certificate generated at ~/.kbot/certs/');
67
+ return { cert: readFileSync(certPath, 'utf-8'), key: readFileSync(keyPath, 'utf-8') };
68
+ }
45
69
  export async function startServe(options) {
46
70
  // Register all tools before starting
47
71
  printInfo('Registering tools...');
48
72
  await registerAllTools({ computerUse: options.computerUse });
49
73
  const tools = getAllTools();
50
74
  printSuccess(`${tools.length} tools registered`);
51
- const server = createServer(async (req, res) => {
75
+ // Determine TLS config
76
+ const useTls = !!(options.https || options.cert || options.key);
77
+ let tlsOpts;
78
+ if (useTls) {
79
+ if (options.cert && options.key) {
80
+ tlsOpts = { cert: readFileSync(options.cert, 'utf-8'), key: readFileSync(options.key, 'utf-8') };
81
+ }
82
+ else {
83
+ tlsOpts = ensureSelfSignedCert();
84
+ }
85
+ }
86
+ const protocol = useTls ? 'https' : 'http';
87
+ const handler = async (req, res) => {
52
88
  // CORS preflight
53
89
  if (req.method === 'OPTIONS') {
54
90
  cors(res);
@@ -66,7 +102,7 @@ export async function startServe(options) {
66
102
  return;
67
103
  }
68
104
  }
69
- const url = new URL(req.url || '/', `http://localhost:${options.port}`);
105
+ const url = new URL(req.url || '/', `${protocol}://localhost:${options.port}`);
70
106
  const path = url.pathname;
71
107
  try {
72
108
  // GET /health
@@ -218,15 +254,23 @@ export async function startServe(options) {
218
254
  catch (err) {
219
255
  json(res, 500, { error: err instanceof Error ? err.message : 'Internal error' });
220
256
  }
221
- });
257
+ };
258
+ const server = useTls
259
+ ? createHttpsServer(tlsOpts, handler)
260
+ : createServer(handler);
222
261
  // Mount A2A protocol routes (Agent Card + task endpoints)
262
+ // Cast needed: https.Server and http.Server share the same request event API
223
263
  mountA2ARoutes(server, {
224
264
  port: options.port,
225
- endpointUrl: `http://localhost:${options.port}`,
265
+ endpointUrl: `${protocol}://localhost:${options.port}`,
226
266
  token: options.token,
227
267
  });
268
+ const baseUrl = `${protocol}://localhost:${options.port}`;
228
269
  server.listen(options.port, () => {
229
- printSuccess(`kbot serve running on http://localhost:${options.port}`);
270
+ printSuccess(`kbot serve running on ${baseUrl}`);
271
+ if (useTls) {
272
+ printInfo(' TLS: self-signed cert (browsers will warn — safe for local connectors)');
273
+ }
230
274
  printInfo(` GET /health — Health check`);
231
275
  printInfo(` GET /tools — List ${tools.length} tools`);
232
276
  printInfo(` POST /execute — Execute a tool`);
@@ -243,7 +287,7 @@ export async function startServe(options) {
243
287
  }
244
288
  printInfo('');
245
289
  printInfo('Connect from kernel.chat:');
246
- printInfo(` connectKbot('http://localhost:${options.port}')`);
290
+ printInfo(` connectKbot('${baseUrl}')`);
247
291
  });
248
292
  // Graceful shutdown
249
293
  const shutdown = () => {
@@ -1,12 +1,13 @@
1
1
  // kbot Buddy Tools — Interact with your terminal companion
2
2
  //
3
- // Four tools:
3
+ // Five tools:
4
4
  // buddy_status — Show buddy name, species, mood, and sprite
5
5
  // buddy_rename — Give your buddy a custom name (persisted to ~/.kbot/buddy.json)
6
6
  // buddy_achievements — Show all achievements with unlock status and progress
7
7
  // buddy_personality — Show species personality traits, style, and strength
8
+ // buddy_leaderboard — Global anonymous leaderboard across all kbot installs
8
9
  import { registerTool } from './index.js';
9
- import { getBuddy, getBuddySprite, getBuddyGreeting, getBuddyLevel, formatBuddyStatus, renameBuddy, getAchievements, getAchievementProgress, getSpeciesPersonality, } from '../buddy.js';
10
+ import { getBuddy, getBuddySprite, getBuddyGreeting, getBuddyLevel, formatBuddyStatus, renameBuddy, getAchievements, getAchievementProgress, getSpeciesPersonality, fetchBuddyLeaderboard, } from '../buddy.js';
10
11
  const VALID_MOODS = ['idle', 'thinking', 'success', 'error', 'learning', 'alert', 'dance', 'curious', 'proud'];
11
12
  export function registerBuddyTools() {
12
13
  registerTool({
@@ -124,5 +125,57 @@ export function registerBuddyTools() {
124
125
  ].join('\n');
125
126
  },
126
127
  });
128
+ registerTool({
129
+ name: 'buddy_leaderboard',
130
+ description: 'Show the global buddy leaderboard — anonymous rankings of all kbot buddies across installs, sorted by XP. Requires cloud sync (kernel.chat token).',
131
+ parameters: {
132
+ limit: {
133
+ type: 'number',
134
+ description: 'Number of entries to show (default 20, max 200)',
135
+ },
136
+ species: {
137
+ type: 'string',
138
+ description: 'Filter by species: fox, owl, cat, robot, ghost, mushroom, octopus, dragon',
139
+ },
140
+ },
141
+ tier: 'free',
142
+ async execute(args) {
143
+ const SPECIES_ICONS = {
144
+ fox: '[fox]', owl: '[owl]', cat: '[cat]', robot: '[bot]',
145
+ ghost: '[gho]', mushroom: '[msh]', octopus: '[oct]', dragon: '[drg]',
146
+ };
147
+ const LEVEL_TITLES_SHORT = {
148
+ 0: 'Novice', 1: 'Adept', 2: 'Master', 3: 'Legend',
149
+ };
150
+ const limit = Math.min(Math.max(Math.floor(Number(args.limit) || 20), 1), 200);
151
+ const species = args.species ? String(args.species).toLowerCase() : undefined;
152
+ const validSpecies = ['fox', 'owl', 'cat', 'robot', 'ghost', 'mushroom', 'octopus', 'dragon'];
153
+ if (species && !validSpecies.includes(species)) {
154
+ return `Unknown species "${species}". Valid: ${validSpecies.join(', ')}`;
155
+ }
156
+ const entries = await fetchBuddyLeaderboard({ limit, species });
157
+ if (entries.length === 0) {
158
+ return 'No entries on the leaderboard yet. Use kbot to earn XP and sync to the cloud!';
159
+ }
160
+ const lines = [];
161
+ const header = species
162
+ ? `=== Buddy Leaderboard — ${species} ===`
163
+ : '=== Global Buddy Leaderboard ===';
164
+ lines.push(header);
165
+ lines.push('');
166
+ // Table header
167
+ lines.push(` ${'#'.padStart(3)} ${'Species'.padEnd(7)} ${'Level'.padEnd(12)} ${'XP'.padStart(6)} ${'Achv'.padStart(4)} ${'Sessions'.padStart(8)}`);
168
+ lines.push(` ${'─'.repeat(3)} ${'─'.repeat(7)} ${'─'.repeat(12)} ${'─'.repeat(6)} ${'─'.repeat(4)} ${'─'.repeat(8)}`);
169
+ for (const entry of entries) {
170
+ const icon = SPECIES_ICONS[entry.species] || entry.species.slice(0, 5);
171
+ const title = LEVEL_TITLES_SHORT[entry.level] ?? `L${entry.level}`;
172
+ const levelStr = `${entry.level} ${title}`;
173
+ lines.push(` ${String(entry.rank).padStart(3)} ${icon.padEnd(7)} ${levelStr.padEnd(12)} ${String(entry.xp).padStart(6)} ${String(entry.achievement_count).padStart(4)} ${String(entry.sessions).padStart(8)}`);
174
+ }
175
+ lines.push('');
176
+ lines.push(`${entries.length} entries shown`);
177
+ return lines.join('\n');
178
+ },
179
+ });
127
180
  }
128
181
  //# sourceMappingURL=buddy-tools.js.map
@@ -311,6 +311,8 @@ const LAZY_MODULE_IMPORTS = [
311
311
  { path: './financial-analysis.js', registerFn: 'registerFinancialAnalysisTools' },
312
312
  { path: './ai-analysis.js', registerFn: 'registerAIAnalysisTools' },
313
313
  { path: './music-gen.js', registerFn: 'registerMusicGenTools' },
314
+ { path: './mobile-automation.js', registerFn: 'registerMobileAutomationTools' },
315
+ { path: './iphone.js', registerFn: 'registerIPhoneTools' },
314
316
  ];
315
317
  /** Track whether lazy tools have been registered */
316
318
  let lazyToolsRegistered = false;
@@ -0,0 +1,2 @@
1
+ export declare function registerIPhoneTools(): void;
2
+ //# sourceMappingURL=iphone.d.ts.map