@kernel.chat/kbot 3.50.0 → 3.52.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 (84) hide show
  1. package/README.md +43 -9
  2. package/dist/agent-protocol.test.d.ts +2 -0
  3. package/dist/agent-protocol.test.d.ts.map +1 -0
  4. package/dist/agent-protocol.test.js +730 -0
  5. package/dist/agent-protocol.test.js.map +1 -0
  6. package/dist/agent.d.ts.map +1 -1
  7. package/dist/agent.js +34 -10
  8. package/dist/agent.js.map +1 -1
  9. package/dist/auth.js +3 -3
  10. package/dist/auth.js.map +1 -1
  11. package/dist/bench.d.ts +64 -0
  12. package/dist/bench.d.ts.map +1 -0
  13. package/dist/bench.js +973 -0
  14. package/dist/bench.js.map +1 -0
  15. package/dist/cli.js +144 -29
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-agent.d.ts +77 -0
  18. package/dist/cloud-agent.d.ts.map +1 -0
  19. package/dist/cloud-agent.js +743 -0
  20. package/dist/cloud-agent.js.map +1 -0
  21. package/dist/context.test.d.ts +2 -0
  22. package/dist/context.test.d.ts.map +1 -0
  23. package/dist/context.test.js +561 -0
  24. package/dist/context.test.js.map +1 -0
  25. package/dist/evolution.d.ts.map +1 -1
  26. package/dist/evolution.js +4 -1
  27. package/dist/evolution.js.map +1 -1
  28. package/dist/github-release.d.ts +61 -0
  29. package/dist/github-release.d.ts.map +1 -0
  30. package/dist/github-release.js +451 -0
  31. package/dist/github-release.js.map +1 -0
  32. package/dist/graph-memory.test.d.ts +2 -0
  33. package/dist/graph-memory.test.d.ts.map +1 -0
  34. package/dist/graph-memory.test.js +946 -0
  35. package/dist/graph-memory.test.js.map +1 -0
  36. package/dist/init-science.d.ts +43 -0
  37. package/dist/init-science.d.ts.map +1 -0
  38. package/dist/init-science.js +477 -0
  39. package/dist/init-science.js.map +1 -0
  40. package/dist/lab.d.ts +45 -0
  41. package/dist/lab.d.ts.map +1 -0
  42. package/dist/lab.js +1020 -0
  43. package/dist/lab.js.map +1 -0
  44. package/dist/lsp-deep.d.ts +101 -0
  45. package/dist/lsp-deep.d.ts.map +1 -0
  46. package/dist/lsp-deep.js +689 -0
  47. package/dist/lsp-deep.js.map +1 -0
  48. package/dist/memory.test.d.ts +2 -0
  49. package/dist/memory.test.d.ts.map +1 -0
  50. package/dist/memory.test.js +369 -0
  51. package/dist/memory.test.js.map +1 -0
  52. package/dist/multi-session.d.ts +164 -0
  53. package/dist/multi-session.d.ts.map +1 -0
  54. package/dist/multi-session.js +885 -0
  55. package/dist/multi-session.js.map +1 -0
  56. package/dist/self-eval.d.ts.map +1 -1
  57. package/dist/self-eval.js +5 -2
  58. package/dist/self-eval.js.map +1 -1
  59. package/dist/streaming.d.ts.map +1 -1
  60. package/dist/streaming.js +0 -1
  61. package/dist/streaming.js.map +1 -1
  62. package/dist/teach.d.ts +136 -0
  63. package/dist/teach.d.ts.map +1 -0
  64. package/dist/teach.js +915 -0
  65. package/dist/teach.js.map +1 -0
  66. package/dist/telemetry.d.ts +1 -1
  67. package/dist/telemetry.d.ts.map +1 -1
  68. package/dist/telemetry.js.map +1 -1
  69. package/dist/tools/ableton.d.ts.map +1 -1
  70. package/dist/tools/ableton.js +255 -1
  71. package/dist/tools/ableton.js.map +1 -1
  72. package/dist/tools/browser-agent.js +2 -2
  73. package/dist/tools/browser-agent.js.map +1 -1
  74. package/dist/tools/forge.d.ts.map +1 -1
  75. package/dist/tools/forge.js +15 -26
  76. package/dist/tools/forge.js.map +1 -1
  77. package/dist/tools/git.d.ts.map +1 -1
  78. package/dist/tools/git.js +10 -7
  79. package/dist/tools/git.js.map +1 -1
  80. package/dist/voice-realtime.d.ts +54 -0
  81. package/dist/voice-realtime.d.ts.map +1 -0
  82. package/dist/voice-realtime.js +805 -0
  83. package/dist/voice-realtime.js.map +1 -0
  84. package/package.json +10 -3
@@ -0,0 +1,885 @@
1
+ // kbot Multi-Session — Parallel Named Sessions
2
+ //
3
+ // Run multiple independent kbot agent sessions on the same project.
4
+ // Each session gets isolated conversation history but shares learning
5
+ // data, memory, teachings, and user profile.
6
+ //
7
+ // Sessions can run in the foreground (one at a time) or background
8
+ // (via child_process.fork). They communicate through a simple message bus.
9
+ //
10
+ // Storage: ~/.kbot/sessions/managed/
11
+ // Limit: 8 concurrent sessions (prevents resource exhaustion)
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, } from 'node:fs';
15
+ import { fork } from 'node:child_process';
16
+ import chalk from 'chalk';
17
+ // ── Constants ──
18
+ const MANAGED_DIR = join(homedir(), '.kbot', 'sessions', 'managed');
19
+ const MESSAGE_BUS_DIR = join(homedir(), '.kbot', 'sessions', 'bus');
20
+ const MAX_CONCURRENT_SESSIONS = 8;
21
+ const MAX_HISTORY_TURNS = 100;
22
+ const SESSION_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
23
+ // ── NO_COLOR support ──
24
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY !== false;
25
+ const colors = {
26
+ green: useColor ? chalk.hex('#4ADE80') : (s) => s,
27
+ yellow: useColor ? chalk.hex('#FBBF24') : (s) => s,
28
+ blue: useColor ? chalk.hex('#60A5FA') : (s) => s,
29
+ dim: useColor ? chalk.dim : (s) => s,
30
+ accent: useColor ? chalk.hex('#A78BFA') : (s) => s,
31
+ red: useColor ? chalk.hex('#F87171') : (s) => s,
32
+ cyan: useColor ? chalk.hex('#67E8F9') : (s) => s,
33
+ bold: useColor ? chalk.bold : (s) => s,
34
+ white: useColor ? chalk.white : (s) => s,
35
+ };
36
+ // ── Internal state ──
37
+ /** The currently active foreground session ID */
38
+ let activeSessionId = null;
39
+ /** Map of background child processes by session ID */
40
+ const backgroundProcesses = new Map();
41
+ // ── Directory management ──
42
+ function ensureManagedDir() {
43
+ if (!existsSync(MANAGED_DIR))
44
+ mkdirSync(MANAGED_DIR, { recursive: true });
45
+ }
46
+ function ensureBusDir() {
47
+ if (!existsSync(MESSAGE_BUS_DIR))
48
+ mkdirSync(MESSAGE_BUS_DIR, { recursive: true });
49
+ }
50
+ function sessionPath(id) {
51
+ return join(MANAGED_DIR, `${id}.json`);
52
+ }
53
+ function busInboxPath(sessionId) {
54
+ return join(MESSAGE_BUS_DIR, `${sessionId}.json`);
55
+ }
56
+ // ── ID generation ──
57
+ function generateId() {
58
+ const now = new Date();
59
+ const date = now.toISOString().split('T')[0].replace(/-/g, '');
60
+ const rand = Math.random().toString(36).slice(2, 6);
61
+ return `ms-${date}-${rand}`;
62
+ }
63
+ function slugify(name) {
64
+ return name
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9\s-]/g, '')
67
+ .replace(/\s+/g, '-')
68
+ .slice(0, 40);
69
+ }
70
+ // ── Persistence helpers ──
71
+ function readSession(id) {
72
+ const path = sessionPath(id);
73
+ if (!existsSync(path))
74
+ return null;
75
+ try {
76
+ return JSON.parse(readFileSync(path, 'utf-8'));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ function writeSession(session) {
83
+ ensureManagedDir();
84
+ writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2));
85
+ }
86
+ function readBusInbox(sessionId) {
87
+ const path = busInboxPath(sessionId);
88
+ if (!existsSync(path))
89
+ return [];
90
+ try {
91
+ return JSON.parse(readFileSync(path, 'utf-8'));
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ }
97
+ function writeBusInbox(sessionId, messages) {
98
+ ensureBusDir();
99
+ writeFileSync(busInboxPath(sessionId), JSON.stringify(messages, null, 2));
100
+ }
101
+ // ── Lookup helper ──
102
+ /**
103
+ * Find a session by exact ID, slug match, or fuzzy name match.
104
+ * Used by all public functions that accept nameOrId.
105
+ */
106
+ function findSession(nameOrId) {
107
+ ensureManagedDir();
108
+ // 1. Try exact ID match
109
+ const exact = readSession(nameOrId);
110
+ if (exact)
111
+ return exact;
112
+ // 2. Try slugified name as ID
113
+ const slugged = slugify(nameOrId);
114
+ const bySlug = readSession(slugged);
115
+ if (bySlug)
116
+ return bySlug;
117
+ // 3. Scan all sessions for partial ID or name match
118
+ const files = readdirSync(MANAGED_DIR).filter(f => f.endsWith('.json'));
119
+ const lowerQuery = nameOrId.toLowerCase();
120
+ for (const file of files) {
121
+ const id = file.replace('.json', '');
122
+ if (id.includes(lowerQuery)) {
123
+ return readSession(id);
124
+ }
125
+ }
126
+ // 4. Fuzzy match on name field
127
+ for (const file of files) {
128
+ const session = readSession(file.replace('.json', ''));
129
+ if (session && session.name.toLowerCase().includes(lowerQuery)) {
130
+ return session;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ // ── Validation ──
136
+ function countActiveSessions() {
137
+ const sessions = listSessions();
138
+ return sessions.filter(s => s.status === 'active' || s.status === 'background').length;
139
+ }
140
+ function validateSessionLimit() {
141
+ const active = countActiveSessions();
142
+ if (active >= MAX_CONCURRENT_SESSIONS) {
143
+ throw new Error(`Session limit reached (${MAX_CONCURRENT_SESSIONS} concurrent). ` +
144
+ `Kill or pause a session first. Use listSessions() to see all sessions.`);
145
+ }
146
+ }
147
+ function validateUniqueName(name) {
148
+ const existing = findSession(name);
149
+ if (existing && existing.status !== 'completed') {
150
+ throw new Error(`Session "${name}" already exists (status: ${existing.status}). ` +
151
+ `Use a different name or kill the existing session.`);
152
+ }
153
+ }
154
+ // ── Core API ──
155
+ /**
156
+ * Create a new named session.
157
+ * Each session starts with empty history and tracks its own token usage.
158
+ */
159
+ export function createSession(opts) {
160
+ validateSessionLimit();
161
+ validateUniqueName(opts.name);
162
+ const id = slugify(opts.name) || generateId();
163
+ const now = new Date().toISOString();
164
+ const session = {
165
+ id,
166
+ name: opts.name,
167
+ status: 'active',
168
+ agent: opts.agent,
169
+ task: opts.task,
170
+ history: [],
171
+ createdAt: now,
172
+ lastActiveAt: now,
173
+ tokenUsage: { input: 0, output: 0 },
174
+ toolCalls: 0,
175
+ };
176
+ writeSession(session);
177
+ // Set as active foreground session
178
+ activeSessionId = session.id;
179
+ return session;
180
+ }
181
+ /**
182
+ * List all managed sessions, newest first.
183
+ * Cleans up stale background sessions whose processes are no longer running.
184
+ */
185
+ export function listSessions() {
186
+ ensureManagedDir();
187
+ const files = readdirSync(MANAGED_DIR).filter(f => f.endsWith('.json'));
188
+ const sessions = [];
189
+ for (const file of files) {
190
+ const session = readSession(file.replace('.json', ''));
191
+ if (session) {
192
+ // Clean up stale background sessions
193
+ if (session.status === 'background' && session.pid) {
194
+ if (!isProcessRunning(session.pid)) {
195
+ session.status = 'completed';
196
+ session.pid = undefined;
197
+ writeSession(session);
198
+ }
199
+ }
200
+ sessions.push(session);
201
+ }
202
+ }
203
+ return sessions.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
204
+ }
205
+ /**
206
+ * Switch the active foreground session.
207
+ * The previous active session is paused. Returns the newly active session.
208
+ */
209
+ export function switchSession(nameOrId) {
210
+ const target = findSession(nameOrId);
211
+ if (!target)
212
+ return null;
213
+ if (target.status === 'completed') {
214
+ throw new Error(`Cannot switch to completed session "${target.name}". Resume it first.`);
215
+ }
216
+ // Pause current active session
217
+ if (activeSessionId && activeSessionId !== target.id) {
218
+ const current = readSession(activeSessionId);
219
+ if (current && current.status === 'active') {
220
+ current.status = 'paused';
221
+ current.lastActiveAt = new Date().toISOString();
222
+ writeSession(current);
223
+ }
224
+ }
225
+ // Activate target
226
+ target.status = 'active';
227
+ target.lastActiveAt = new Date().toISOString();
228
+ writeSession(target);
229
+ activeSessionId = target.id;
230
+ return target;
231
+ }
232
+ /**
233
+ * Send a message to a background session.
234
+ * The message is delivered via the message bus and the session's
235
+ * background process picks it up on its next iteration.
236
+ */
237
+ export async function sendToSession(nameOrId, message) {
238
+ const target = findSession(nameOrId);
239
+ if (!target)
240
+ return `Session "${nameOrId}" not found.`;
241
+ if (target.status !== 'background') {
242
+ return `Session "${target.name}" is not running in background (status: ${target.status}).`;
243
+ }
244
+ // Deliver via message bus
245
+ const busMessage = {
246
+ from: activeSessionId || 'user',
247
+ message,
248
+ timestamp: new Date().toISOString(),
249
+ };
250
+ const inbox = readBusInbox(target.id);
251
+ inbox.push(busMessage);
252
+ writeBusInbox(target.id, inbox);
253
+ // Also try IPC if the background process is still tracked
254
+ const proc = backgroundProcesses.get(target.id);
255
+ if (proc && proc.connected) {
256
+ try {
257
+ proc.send({ type: 'message', from: busMessage.from, content: message });
258
+ }
259
+ catch {
260
+ // IPC send failed — message is still in the file-based bus
261
+ }
262
+ }
263
+ return `Message delivered to "${target.name}".`;
264
+ }
265
+ /**
266
+ * Pause a session — preserve context, free resources.
267
+ * Background sessions have their child process killed.
268
+ */
269
+ export function pauseSession(nameOrId) {
270
+ const session = findSession(nameOrId);
271
+ if (!session)
272
+ return false;
273
+ if (session.status === 'completed' || session.status === 'paused')
274
+ return false;
275
+ // Kill background process if running
276
+ if (session.status === 'background' && session.pid) {
277
+ killBackgroundProcess(session.id, session.pid);
278
+ }
279
+ session.status = 'paused';
280
+ session.pid = undefined;
281
+ session.lastActiveAt = new Date().toISOString();
282
+ writeSession(session);
283
+ if (activeSessionId === session.id) {
284
+ activeSessionId = null;
285
+ }
286
+ return true;
287
+ }
288
+ /**
289
+ * Resume a paused session back to active status.
290
+ */
291
+ export function resumeSession(nameOrId) {
292
+ const session = findSession(nameOrId);
293
+ if (!session)
294
+ return null;
295
+ if (session.status !== 'paused' && session.status !== 'completed')
296
+ return null;
297
+ validateSessionLimit();
298
+ session.status = 'active';
299
+ session.lastActiveAt = new Date().toISOString();
300
+ writeSession(session);
301
+ // Auto-switch to this session
302
+ activeSessionId = session.id;
303
+ return session;
304
+ }
305
+ /**
306
+ * Kill a session — terminate background process, mark as completed.
307
+ */
308
+ export function killSession(nameOrId) {
309
+ const session = findSession(nameOrId);
310
+ if (!session)
311
+ return false;
312
+ // Kill background process if running
313
+ if (session.pid) {
314
+ killBackgroundProcess(session.id, session.pid);
315
+ }
316
+ session.status = 'completed';
317
+ session.pid = undefined;
318
+ session.lastActiveAt = new Date().toISOString();
319
+ writeSession(session);
320
+ if (activeSessionId === session.id) {
321
+ activeSessionId = null;
322
+ }
323
+ return true;
324
+ }
325
+ /**
326
+ * Get a session by name or ID.
327
+ */
328
+ export function getSession(nameOrId) {
329
+ return findSession(nameOrId);
330
+ }
331
+ /**
332
+ * Get the currently active foreground session.
333
+ */
334
+ export function getActiveSession() {
335
+ if (!activeSessionId)
336
+ return null;
337
+ return readSession(activeSessionId);
338
+ }
339
+ /**
340
+ * Set the active session ID without changing status.
341
+ * Used internally when restoring state on startup.
342
+ */
343
+ export function setActiveSessionId(id) {
344
+ activeSessionId = id;
345
+ }
346
+ // ── History management ──
347
+ /**
348
+ * Append a turn to a session's conversation history.
349
+ * Enforces MAX_HISTORY_TURNS — compacts when exceeded.
350
+ */
351
+ export function appendToSessionHistory(nameOrId, turn) {
352
+ const session = findSession(nameOrId);
353
+ if (!session)
354
+ return false;
355
+ session.history.push(turn);
356
+ session.lastActiveAt = new Date().toISOString();
357
+ // Compact if over limit: summarize old turns, keep recent ones
358
+ if (session.history.length > MAX_HISTORY_TURNS) {
359
+ const keepVerbatim = session.history.slice(-20);
360
+ const toSummarize = session.history.slice(0, -20);
361
+ const userTopics = [];
362
+ const assistantTopics = [];
363
+ for (const t of toSummarize) {
364
+ if (t.role === 'user') {
365
+ userTopics.push(t.content.slice(0, 80));
366
+ }
367
+ else {
368
+ assistantTopics.push(t.content.split('\n')[0].slice(0, 80));
369
+ }
370
+ }
371
+ const summary = [
372
+ '[Compacted conversation summary]',
373
+ userTopics.length > 0 ? `User asked about: ${userTopics.join('; ')}` : '',
374
+ assistantTopics.length > 0 ? `Topics covered: ${assistantTopics.join('; ')}` : '',
375
+ ].filter(Boolean).join('\n');
376
+ session.history = [
377
+ { role: 'assistant', content: summary },
378
+ ...keepVerbatim,
379
+ ];
380
+ }
381
+ writeSession(session);
382
+ return true;
383
+ }
384
+ /**
385
+ * Update token usage counters for a session.
386
+ */
387
+ export function updateSessionTokens(nameOrId, input, output, toolCalls) {
388
+ const session = findSession(nameOrId);
389
+ if (!session)
390
+ return false;
391
+ session.tokenUsage.input += input;
392
+ session.tokenUsage.output += output;
393
+ if (toolCalls)
394
+ session.toolCalls += toolCalls;
395
+ session.lastActiveAt = new Date().toISOString();
396
+ writeSession(session);
397
+ return true;
398
+ }
399
+ // ── Background execution ──
400
+ /**
401
+ * Move a session to background execution.
402
+ * Forks a child process that continues the session independently.
403
+ * The child shares ~/.kbot/ learning data but has isolated history.
404
+ */
405
+ export function backgroundSession(nameOrId, scriptPath) {
406
+ const session = findSession(nameOrId);
407
+ if (!session)
408
+ return null;
409
+ if (session.status === 'background') {
410
+ throw new Error(`Session "${session.name}" is already running in background.`);
411
+ }
412
+ if (session.status === 'completed') {
413
+ throw new Error(`Cannot background a completed session. Resume it first.`);
414
+ }
415
+ validateSessionLimit();
416
+ // Use the provided script path or fall back to default background worker
417
+ const workerScript = scriptPath || join(__dirname, 'session-worker.js');
418
+ if (!existsSync(workerScript)) {
419
+ // If no worker script exists, we still mark the session as background
420
+ // and rely on external orchestration (e.g., planner.ts or daemon)
421
+ session.status = 'background';
422
+ session.lastActiveAt = new Date().toISOString();
423
+ writeSession(session);
424
+ if (activeSessionId === session.id) {
425
+ activeSessionId = null;
426
+ }
427
+ return session;
428
+ }
429
+ try {
430
+ const child = fork(workerScript, [session.id], {
431
+ detached: true,
432
+ stdio: 'ignore',
433
+ env: {
434
+ ...process.env,
435
+ KBOT_SESSION_ID: session.id,
436
+ KBOT_SESSION_NAME: session.name,
437
+ KBOT_SESSION_AGENT: session.agent || '',
438
+ KBOT_SESSION_TASK: session.task || '',
439
+ },
440
+ });
441
+ child.unref();
442
+ session.status = 'background';
443
+ session.pid = child.pid;
444
+ session.lastActiveAt = new Date().toISOString();
445
+ writeSession(session);
446
+ backgroundProcesses.set(session.id, child);
447
+ // Listen for exit
448
+ child.on('exit', (code) => {
449
+ backgroundProcesses.delete(session.id);
450
+ const s = readSession(session.id);
451
+ if (s && s.status === 'background') {
452
+ s.status = 'completed';
453
+ s.pid = undefined;
454
+ s.lastActiveAt = new Date().toISOString();
455
+ writeSession(s);
456
+ }
457
+ });
458
+ if (activeSessionId === session.id) {
459
+ activeSessionId = null;
460
+ }
461
+ return session;
462
+ }
463
+ catch (err) {
464
+ // Fork failed — mark as paused so user can retry
465
+ session.status = 'paused';
466
+ session.lastActiveAt = new Date().toISOString();
467
+ writeSession(session);
468
+ throw new Error(`Failed to background session "${session.name}": ${err}`);
469
+ }
470
+ }
471
+ // ── Message bus ──
472
+ /**
473
+ * Broadcast a message to all active/background sessions.
474
+ * Messages are delivered via the file-based bus — each session has an inbox.
475
+ */
476
+ export function broadcastToSessions(message, fromSession) {
477
+ ensureBusDir();
478
+ const sessions = listSessions();
479
+ const sender = fromSession || activeSessionId || 'system';
480
+ const busMessage = {
481
+ from: sender,
482
+ message,
483
+ timestamp: new Date().toISOString(),
484
+ };
485
+ for (const session of sessions) {
486
+ // Don't send to the sender or to completed sessions
487
+ if (session.id === sender)
488
+ continue;
489
+ if (session.status === 'completed')
490
+ continue;
491
+ const inbox = readBusInbox(session.id);
492
+ inbox.push(busMessage);
493
+ // Keep inbox bounded — last 50 messages
494
+ const trimmed = inbox.slice(-50);
495
+ writeBusInbox(session.id, trimmed);
496
+ // Also try IPC for background sessions
497
+ const proc = backgroundProcesses.get(session.id);
498
+ if (proc && proc.connected) {
499
+ try {
500
+ proc.send({ type: 'broadcast', from: sender, content: message });
501
+ }
502
+ catch {
503
+ // IPC failed — file bus is the fallback
504
+ }
505
+ }
506
+ }
507
+ }
508
+ /**
509
+ * Get pending messages for a session and clear the inbox.
510
+ */
511
+ export function getSessionMessages(sessionId) {
512
+ const messages = readBusInbox(sessionId);
513
+ // Clear inbox after reading
514
+ if (messages.length > 0) {
515
+ writeBusInbox(sessionId, []);
516
+ }
517
+ return messages;
518
+ }
519
+ /**
520
+ * Peek at messages without clearing (non-destructive read).
521
+ */
522
+ export function peekSessionMessages(sessionId) {
523
+ return readBusInbox(sessionId);
524
+ }
525
+ // ── Session cleanup ──
526
+ /**
527
+ * Remove completed sessions older than the stale threshold.
528
+ */
529
+ export function pruneCompletedSessions(maxAge = SESSION_STALE_MS) {
530
+ ensureManagedDir();
531
+ const files = readdirSync(MANAGED_DIR).filter(f => f.endsWith('.json'));
532
+ const now = Date.now();
533
+ let pruned = 0;
534
+ for (const file of files) {
535
+ const session = readSession(file.replace('.json', ''));
536
+ if (!session)
537
+ continue;
538
+ if (session.status !== 'completed')
539
+ continue;
540
+ const age = now - new Date(session.lastActiveAt).getTime();
541
+ if (age > maxAge) {
542
+ const path = sessionPath(session.id);
543
+ if (existsSync(path)) {
544
+ unlinkSync(path);
545
+ pruned++;
546
+ }
547
+ // Also clean up bus inbox
548
+ const busPath = busInboxPath(session.id);
549
+ if (existsSync(busPath)) {
550
+ unlinkSync(busPath);
551
+ }
552
+ }
553
+ }
554
+ return pruned;
555
+ }
556
+ /**
557
+ * Delete a session entirely — removes all data.
558
+ */
559
+ export function deleteSession(nameOrId) {
560
+ const session = findSession(nameOrId);
561
+ if (!session)
562
+ return false;
563
+ // Kill if running
564
+ if (session.pid) {
565
+ killBackgroundProcess(session.id, session.pid);
566
+ }
567
+ // Remove session file
568
+ const path = sessionPath(session.id);
569
+ if (existsSync(path))
570
+ unlinkSync(path);
571
+ // Remove bus inbox
572
+ const busPath = busInboxPath(session.id);
573
+ if (existsSync(busPath))
574
+ unlinkSync(busPath);
575
+ if (activeSessionId === session.id) {
576
+ activeSessionId = null;
577
+ }
578
+ backgroundProcesses.delete(session.id);
579
+ return true;
580
+ }
581
+ // ── Display formatting ──
582
+ const STATUS_LABELS = {
583
+ active: colors.green,
584
+ paused: colors.yellow,
585
+ background: colors.blue,
586
+ completed: colors.dim,
587
+ };
588
+ const STATUS_ICONS = {
589
+ active: '●',
590
+ paused: '◐',
591
+ background: '◉',
592
+ completed: '○',
593
+ };
594
+ /**
595
+ * Format a list of sessions as a table for terminal display.
596
+ */
597
+ export function formatSessionList(sessions) {
598
+ if (sessions.length === 0) {
599
+ return colors.dim(' No managed sessions.');
600
+ }
601
+ const lines = [];
602
+ // Header
603
+ lines.push(colors.bold(' Sessions'));
604
+ lines.push(colors.dim(' ' + '─'.repeat(72)));
605
+ // Column header
606
+ lines.push(colors.dim(' ' +
607
+ pad('Status', 12) +
608
+ pad('Name', 22) +
609
+ pad('Agent', 14) +
610
+ pad('Tokens', 12) +
611
+ 'Task'));
612
+ lines.push(colors.dim(' ' + '─'.repeat(72)));
613
+ for (const session of sessions) {
614
+ const statusColor = STATUS_LABELS[session.status];
615
+ const icon = STATUS_ICONS[session.status];
616
+ const isActive = session.id === activeSessionId;
617
+ const statusStr = statusColor(`${icon} ${session.status}`);
618
+ const nameStr = isActive
619
+ ? colors.accent(`${session.name} *`)
620
+ : colors.white(session.name);
621
+ const agentStr = session.agent ? colors.cyan(session.agent) : colors.dim('—');
622
+ const totalTokens = session.tokenUsage.input + session.tokenUsage.output;
623
+ const tokenStr = totalTokens > 0 ? formatTokenCount(totalTokens) : colors.dim('0');
624
+ const taskStr = session.task
625
+ ? colors.dim(truncate(session.task, 30))
626
+ : colors.dim('—');
627
+ lines.push(' ' +
628
+ pad(statusStr, 12 + ansiLenDiff(statusStr, `${icon} ${session.status}`)) +
629
+ pad(nameStr, 22 + ansiLenDiff(nameStr, isActive ? `${session.name} *` : session.name)) +
630
+ pad(agentStr, 14 + ansiLenDiff(agentStr, session.agent || '—')) +
631
+ pad(tokenStr, 12 + ansiLenDiff(tokenStr, totalTokens > 0 ? formatTokenCountRaw(totalTokens) : '0')) +
632
+ taskStr);
633
+ }
634
+ lines.push(colors.dim(' ' + '─'.repeat(72)));
635
+ // Footer summary
636
+ const active = sessions.filter(s => s.status === 'active').length;
637
+ const background = sessions.filter(s => s.status === 'background').length;
638
+ const paused = sessions.filter(s => s.status === 'paused').length;
639
+ const parts = [];
640
+ if (active > 0)
641
+ parts.push(colors.green(`${active} active`));
642
+ if (background > 0)
643
+ parts.push(colors.blue(`${background} background`));
644
+ if (paused > 0)
645
+ parts.push(colors.yellow(`${paused} paused`));
646
+ parts.push(colors.dim(`${sessions.length} total`));
647
+ lines.push(` ${parts.join(colors.dim(' · '))}`);
648
+ return lines.join('\n');
649
+ }
650
+ /**
651
+ * Format a single session's status for detailed display.
652
+ */
653
+ export function formatSessionStatus(session) {
654
+ const lines = [];
655
+ const statusColor = STATUS_LABELS[session.status];
656
+ const icon = STATUS_ICONS[session.status];
657
+ const isActive = session.id === activeSessionId;
658
+ // Header
659
+ lines.push(colors.bold(` ${session.name}`) +
660
+ (isActive ? colors.accent(' (active)') : '') +
661
+ ' ' + statusColor(`${icon} ${session.status}`));
662
+ lines.push(colors.dim(' ' + '─'.repeat(50)));
663
+ // Details
664
+ lines.push(` ${colors.dim('ID:')} ${session.id}`);
665
+ if (session.agent) {
666
+ lines.push(` ${colors.dim('Agent:')} ${colors.cyan(session.agent)}`);
667
+ }
668
+ if (session.task) {
669
+ lines.push(` ${colors.dim('Task:')} ${session.task}`);
670
+ }
671
+ if (session.pid) {
672
+ lines.push(` ${colors.dim('PID:')} ${session.pid}`);
673
+ }
674
+ // Timeline
675
+ lines.push(` ${colors.dim('Created:')} ${formatTimestamp(session.createdAt)}`);
676
+ lines.push(` ${colors.dim('Last seen:')} ${formatTimestamp(session.lastActiveAt)}`);
677
+ // Stats
678
+ const totalTokens = session.tokenUsage.input + session.tokenUsage.output;
679
+ lines.push(` ${colors.dim('Tokens:')} ${formatTokenCount(session.tokenUsage.input)} in / ` +
680
+ `${formatTokenCount(session.tokenUsage.output)} out ` +
681
+ colors.dim(`(${formatTokenCount(totalTokens)} total)`));
682
+ lines.push(` ${colors.dim('Tools:')} ${session.toolCalls} calls`);
683
+ lines.push(` ${colors.dim('History:')} ${session.history.length} turns`);
684
+ // Recent history preview
685
+ if (session.history.length > 0) {
686
+ lines.push('');
687
+ lines.push(colors.dim(' Recent:'));
688
+ const recent = session.history.slice(-4);
689
+ for (const turn of recent) {
690
+ const role = turn.role === 'user' ? colors.green('you') : colors.accent('kbot');
691
+ const preview = truncate(turn.content.replace(/\n/g, ' '), 60);
692
+ lines.push(` ${role}: ${colors.dim(preview)}`);
693
+ }
694
+ }
695
+ // Pending messages
696
+ const messages = peekSessionMessages(session.id);
697
+ if (messages.length > 0) {
698
+ lines.push('');
699
+ lines.push(colors.yellow(` ${messages.length} pending message${messages.length > 1 ? 's' : ''}`));
700
+ }
701
+ return lines.join('\n');
702
+ }
703
+ // ── Utilities ──
704
+ /**
705
+ * Check if a process is still running by PID.
706
+ */
707
+ function isProcessRunning(pid) {
708
+ try {
709
+ process.kill(pid, 0); // Signal 0 = check existence, don't kill
710
+ return true;
711
+ }
712
+ catch {
713
+ return false;
714
+ }
715
+ }
716
+ /**
717
+ * Kill a background process by PID and clean up.
718
+ */
719
+ function killBackgroundProcess(sessionId, pid) {
720
+ try {
721
+ process.kill(pid, 'SIGTERM');
722
+ }
723
+ catch {
724
+ // Process already dead
725
+ }
726
+ backgroundProcesses.delete(sessionId);
727
+ }
728
+ /**
729
+ * Pad a string to a target width. Accounts for ANSI color codes.
730
+ */
731
+ function pad(str, width) {
732
+ const visible = stripAnsi(str);
733
+ const padding = Math.max(0, width - visible.length);
734
+ return str + ' '.repeat(padding);
735
+ }
736
+ /**
737
+ * Calculate the difference in length between an ANSI-colored string
738
+ * and its visible content. Used for column alignment.
739
+ */
740
+ function ansiLenDiff(ansiStr, plainStr) {
741
+ return ansiStr.length - plainStr.length;
742
+ }
743
+ /**
744
+ * Strip ANSI escape codes for length calculations.
745
+ */
746
+ function stripAnsi(str) {
747
+ // eslint-disable-next-line no-control-regex
748
+ return str.replace(/\u001b\[[0-9;]*m/g, '');
749
+ }
750
+ /**
751
+ * Truncate a string with ellipsis.
752
+ */
753
+ function truncate(str, maxLen) {
754
+ if (str.length <= maxLen)
755
+ return str;
756
+ return str.slice(0, maxLen - 1) + '…';
757
+ }
758
+ /**
759
+ * Format a token count for display (e.g., "12.3K", "1.2M").
760
+ */
761
+ function formatTokenCount(count) {
762
+ const raw = formatTokenCountRaw(count);
763
+ return colors.white(raw);
764
+ }
765
+ function formatTokenCountRaw(count) {
766
+ if (count >= 1_000_000)
767
+ return `${(count / 1_000_000).toFixed(1)}M`;
768
+ if (count >= 1_000)
769
+ return `${(count / 1_000).toFixed(1)}K`;
770
+ return `${count}`;
771
+ }
772
+ /**
773
+ * Format an ISO timestamp as relative time.
774
+ */
775
+ function formatTimestamp(iso) {
776
+ const date = new Date(iso);
777
+ const now = Date.now();
778
+ const diff = now - date.getTime();
779
+ if (diff < 60_000)
780
+ return 'just now';
781
+ if (diff < 3_600_000)
782
+ return `${Math.floor(diff / 60_000)}m ago`;
783
+ if (diff < 86_400_000)
784
+ return `${Math.floor(diff / 3_600_000)}h ago`;
785
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
786
+ }
787
+ // ── Session snapshot / restore ──
788
+ /**
789
+ * Export a session as a portable JSON snapshot.
790
+ * Useful for sharing or archiving.
791
+ */
792
+ export function exportSession(nameOrId) {
793
+ const session = findSession(nameOrId);
794
+ if (!session)
795
+ return null;
796
+ const snapshot = {
797
+ ...session,
798
+ exportedAt: new Date().toISOString(),
799
+ version: 1,
800
+ };
801
+ return JSON.stringify(snapshot, null, 2);
802
+ }
803
+ /**
804
+ * Import a session from a JSON snapshot.
805
+ */
806
+ export function importSession(json) {
807
+ let data;
808
+ try {
809
+ data = JSON.parse(json);
810
+ }
811
+ catch {
812
+ throw new Error('Invalid session JSON.');
813
+ }
814
+ if (!data.id || !data.name || !Array.isArray(data.history)) {
815
+ throw new Error('Invalid session format — missing required fields (id, name, history).');
816
+ }
817
+ validateSessionLimit();
818
+ // Generate a new ID if the original already exists
819
+ const existing = readSession(data.id);
820
+ if (existing) {
821
+ data.id = `${data.id}-${Math.random().toString(36).slice(2, 6)}`;
822
+ }
823
+ const session = {
824
+ id: data.id,
825
+ name: data.name,
826
+ status: 'paused', // Imported sessions start paused
827
+ agent: data.agent,
828
+ task: data.task,
829
+ history: data.history,
830
+ createdAt: data.createdAt || new Date().toISOString(),
831
+ lastActiveAt: new Date().toISOString(),
832
+ tokenUsage: data.tokenUsage || { input: 0, output: 0 },
833
+ toolCalls: data.toolCalls || 0,
834
+ };
835
+ writeSession(session);
836
+ return session;
837
+ }
838
+ // ── Multi-session overview ──
839
+ /**
840
+ * Get a high-level summary of all sessions for inclusion in system prompts.
841
+ * Keeps it compact — just names, statuses, and tasks.
842
+ */
843
+ export function getSessionContextSummary() {
844
+ const sessions = listSessions();
845
+ if (sessions.length === 0)
846
+ return '';
847
+ const active = sessions.filter(s => s.status !== 'completed');
848
+ if (active.length === 0)
849
+ return '';
850
+ const lines = ['[Active Sessions]'];
851
+ for (const s of active) {
852
+ const parts = [`- ${s.name} (${s.status})`];
853
+ if (s.agent)
854
+ parts.push(`agent:${s.agent}`);
855
+ if (s.task)
856
+ parts.push(`task: ${truncate(s.task, 50)}`);
857
+ lines.push(parts.join(' '));
858
+ }
859
+ return lines.join('\n');
860
+ }
861
+ /**
862
+ * Get aggregate stats across all sessions.
863
+ */
864
+ export function getMultiSessionStats() {
865
+ const sessions = listSessions();
866
+ let totalTokensIn = 0;
867
+ let totalTokensOut = 0;
868
+ let totalToolCalls = 0;
869
+ for (const s of sessions) {
870
+ totalTokensIn += s.tokenUsage.input;
871
+ totalTokensOut += s.tokenUsage.output;
872
+ totalToolCalls += s.toolCalls;
873
+ }
874
+ return {
875
+ totalSessions: sessions.length,
876
+ active: sessions.filter(s => s.status === 'active').length,
877
+ paused: sessions.filter(s => s.status === 'paused').length,
878
+ background: sessions.filter(s => s.status === 'background').length,
879
+ completed: sessions.filter(s => s.status === 'completed').length,
880
+ totalTokensIn,
881
+ totalTokensOut,
882
+ totalToolCalls,
883
+ };
884
+ }
885
+ //# sourceMappingURL=multi-session.js.map