@jupyternaut/persona 0.0.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/lib/chat-commands/index.d.ts +3 -0
  2. package/lib/chat-commands/index.js +2 -0
  3. package/lib/chat-commands/mention.d.ts +9 -0
  4. package/lib/chat-commands/mention.js +30 -0
  5. package/lib/chat-commands/skills.d.ts +25 -0
  6. package/lib/chat-commands/skills.js +73 -0
  7. package/lib/completion/completion-provider.d.ts +86 -0
  8. package/lib/completion/completion-provider.js +246 -0
  9. package/lib/completion/index.d.ts +2 -0
  10. package/lib/completion/index.js +1 -0
  11. package/lib/components/completion-status.d.ts +26 -0
  12. package/lib/components/completion-status.js +52 -0
  13. package/lib/components/index.d.ts +2 -0
  14. package/lib/components/index.js +1 -0
  15. package/lib/diff-manager.d.ts +25 -0
  16. package/lib/diff-manager.js +60 -0
  17. package/lib/index.d.ts +8 -0
  18. package/lib/index.js +593 -0
  19. package/lib/models/settings-model.d.ts +36 -0
  20. package/lib/models/settings-model.js +356 -0
  21. package/lib/persona-registry.d.ts +15 -0
  22. package/lib/persona-registry.js +29 -0
  23. package/lib/persona.d.ts +67 -0
  24. package/lib/persona.js +446 -0
  25. package/lib/process-attachments.d.ts +5 -0
  26. package/lib/process-attachments.js +287 -0
  27. package/lib/tokens.d.ts +105 -0
  28. package/lib/tokens.js +20 -0
  29. package/lib/widgets/ai-settings.d.ts +54 -0
  30. package/lib/widgets/ai-settings.js +572 -0
  31. package/lib/widgets/provider-config-dialog.d.ts +16 -0
  32. package/lib/widgets/provider-config-dialog.js +384 -0
  33. package/package.json +120 -7
  34. package/schema/settings-model.json +287 -0
  35. package/src/chat-commands/index.ts +2 -0
  36. package/src/chat-commands/mention.tsx +46 -0
  37. package/src/chat-commands/skills.ts +105 -0
  38. package/src/completion/completion-provider.ts +350 -0
  39. package/src/completion/index.ts +1 -0
  40. package/src/components/completion-status.tsx +93 -0
  41. package/src/components/index.ts +1 -0
  42. package/src/diff-manager.ts +81 -0
  43. package/src/index.ts +808 -0
  44. package/src/models/settings-model.ts +415 -0
  45. package/src/persona-registry.ts +46 -0
  46. package/src/persona.ts +637 -0
  47. package/src/process-attachments.ts +369 -0
  48. package/src/tokens.ts +125 -0
  49. package/src/widgets/ai-settings.tsx +1308 -0
  50. package/src/widgets/provider-config-dialog.tsx +997 -0
  51. package/style/base.css +14 -0
  52. package/style/index.css +1 -0
  53. package/style/index.js +1 -0
  54. package/README.md +0 -3
  55. package/index.js +0 -1
package/src/persona.ts ADDED
@@ -0,0 +1,637 @@
1
+ import {
2
+ IAttachment,
3
+ IMessage,
4
+ IMimeModelBody,
5
+ IChatModel,
6
+ IUser,
7
+ INewMessage
8
+ } from '@jupyter/chat';
9
+
10
+ import * as nbformat from '@jupyterlab/nbformat';
11
+
12
+ import { IRenderMime } from '@jupyterlab/rendermime';
13
+
14
+ import type { IDocumentManager } from '@jupyterlab/docmanager';
15
+
16
+ import type {
17
+ IAgentManager,
18
+ IAISettingsModel,
19
+ IProviderRegistry
20
+ } from '@jupyternaut/agent';
21
+
22
+ import {
23
+ modelSupportsAudio,
24
+ modelSupportsImages,
25
+ modelSupportsPdf
26
+ } from '@jupyternaut/agent';
27
+
28
+ import { ISignal, Signal } from '@lumino/signaling';
29
+
30
+ import type { ModelMessage, UserContent } from 'ai';
31
+
32
+ import { processAttachments } from './process-attachments';
33
+
34
+ import type { IPersona } from './tokens';
35
+
36
+ type ToolStatus =
37
+ | 'pending'
38
+ | 'awaiting_approval'
39
+ | 'approved'
40
+ | 'rejected'
41
+ | 'completed'
42
+ | 'error';
43
+
44
+ interface IToolExecutionContext {
45
+ toolCallId: string;
46
+ messageId: string;
47
+ toolName: string;
48
+ title?: string;
49
+ input: string;
50
+ status: ToolStatus;
51
+ summary?: string;
52
+ shouldAutoRenderMimeBundles?: boolean;
53
+ }
54
+
55
+ function extractToolSummary(toolName: string, input: string): string {
56
+ try {
57
+ const parsed = JSON.parse(input);
58
+ switch (toolName) {
59
+ case 'execute_command':
60
+ return parsed.commandId ?? '';
61
+ case 'discover_commands':
62
+ case 'discover_skills':
63
+ case 'web_search':
64
+ return parsed.query ? `query: "${parsed.query}"` : '';
65
+ case 'load_skill':
66
+ return parsed.name
67
+ ? parsed.resource
68
+ ? `${parsed.name} (${parsed.resource})`
69
+ : parsed.name
70
+ : '';
71
+ case 'browser_fetch':
72
+ case 'web_fetch':
73
+ return parsed.url ?? '';
74
+ }
75
+ } catch {
76
+ // ignore malformed input
77
+ }
78
+ return '';
79
+ }
80
+
81
+ function formatToolOutput(outputData: unknown): string {
82
+ if (typeof outputData === 'string') {
83
+ return outputData;
84
+ }
85
+ try {
86
+ return JSON.stringify(outputData, null, 2);
87
+ } catch {
88
+ return '[Complex object - cannot serialize]';
89
+ }
90
+ }
91
+
92
+ type IDisplayOutput =
93
+ | nbformat.IDisplayData
94
+ | nbformat.IDisplayUpdate
95
+ | nbformat.IExecuteResult;
96
+
97
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
99
+ }
100
+
101
+ function isDisplayOutput(value: unknown): value is IDisplayOutput {
102
+ if (!isPlainObject(value)) {
103
+ return false;
104
+ }
105
+ const output = value as nbformat.IOutput;
106
+ return (
107
+ nbformat.isDisplayData(output) ||
108
+ nbformat.isDisplayUpdate(output) ||
109
+ nbformat.isExecuteResult(output)
110
+ );
111
+ }
112
+
113
+ function toDisplayOutputs(value: unknown): IDisplayOutput[] {
114
+ if (isDisplayOutput(value)) {
115
+ return [value];
116
+ }
117
+ if (Array.isArray(value)) {
118
+ return value.filter(isDisplayOutput);
119
+ }
120
+ if (!isPlainObject(value)) {
121
+ return [];
122
+ }
123
+ if (Array.isArray(value.outputs)) {
124
+ return value.outputs.filter(isDisplayOutput);
125
+ }
126
+ if ('result' in value) {
127
+ return toDisplayOutputs(value.result);
128
+ }
129
+ return [];
130
+ }
131
+
132
+ function extractMimeBundles(
133
+ content: unknown,
134
+ trustedMimeTypes: ReadonlySet<string>
135
+ ): IMimeModelBody[] {
136
+ return toDisplayOutputs(content)
137
+ .map((output): IMimeModelBody | null => {
138
+ const data = output.data;
139
+ if (!isPlainObject(data) || Object.keys(data).length === 0) {
140
+ return null;
141
+ }
142
+ return {
143
+ data: data as IRenderMime.IMimeModel['data'],
144
+ ...(isPlainObject(output.metadata)
145
+ ? {
146
+ metadata: output.metadata as IRenderMime.IMimeModel['metadata']
147
+ }
148
+ : {}),
149
+ ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
150
+ ? { trusted: true }
151
+ : {})
152
+ };
153
+ })
154
+ .filter((b): b is IMimeModelBody => b !== null);
155
+ }
156
+
157
+ /**
158
+ * Links an IAgentManager to an IChatModel for the Jupyternaut persona.
159
+ *
160
+ * Monitors new messages arriving on the chat model and responds when the
161
+ * persona trigger string is mentioned. The handler and its agent stay alive
162
+ * as long as the associated chat widget is open, so conversation history is
163
+ * preserved across multiple mentions.
164
+ */
165
+ export class Persona implements IPersona {
166
+ constructor(options: Persona.IOptions) {
167
+ this._model = options.model;
168
+ this._agent = options.agentManager;
169
+ this._persona = options.persona;
170
+ this._settingsModel = options.settingsModel;
171
+ this._providerRegistry = options.providerRegistry;
172
+ this._documentManager = options.documentManager;
173
+
174
+ this._agent.agentEvent.connect(this._onAgentEvent, this);
175
+ this._agent.activeProviderChanged.connect(
176
+ this._onActiveProviderChanged,
177
+ this
178
+ );
179
+
180
+ // Wait for the chat to be ready before connect to message update.
181
+ this._model.ready.then(() => {
182
+ for (const message of this._model.messages) {
183
+ this._respondedToIds.add(message.id);
184
+ }
185
+ this._model.messagesUpdated.connect(this._onMessagesUpdated, this);
186
+ });
187
+
188
+ this._model.disposed.connect(this.dispose, this);
189
+ }
190
+
191
+ dispose(): void {
192
+ this._agent.agentEvent.disconnect(this._onAgentEvent, this);
193
+ this._agent.activeProviderChanged.disconnect(
194
+ this._onActiveProviderChanged,
195
+ this
196
+ );
197
+ this._model.messagesUpdated.disconnect(this._onMessagesUpdated, this);
198
+ }
199
+
200
+ get agentManager(): IAgentManager {
201
+ return this._agent;
202
+ }
203
+
204
+ get model(): IChatModel {
205
+ return this._model;
206
+ }
207
+
208
+ get isBusy(): boolean {
209
+ return this._busy;
210
+ }
211
+
212
+ get busyChanged(): ISignal<IPersona, boolean> {
213
+ return this._busyChanged;
214
+ }
215
+
216
+ private async _onMessagesUpdated(): Promise<void> {
217
+ const unhandled = this._model.messages.filter(
218
+ m =>
219
+ !this._respondedToIds.has(m.id) &&
220
+ !m.sender.bot &&
221
+ (!this.requireMention || m.mentions?.includes(this._persona))
222
+ );
223
+
224
+ for (const message of unhandled) {
225
+ this._respondedToIds.add(message.id);
226
+ }
227
+
228
+ for (const message of unhandled) {
229
+ const personaMention = `@${this._persona.mention_name}`;
230
+ const body = message.body.replace(personaMention, '').trim();
231
+ await this._respond(body || message.body, message.attachments);
232
+ }
233
+ }
234
+
235
+ private async _respond(
236
+ body: string,
237
+ attachments?: IAttachment[]
238
+ ): Promise<void> {
239
+ this._busy = true;
240
+ this._busyChanged.emit(true);
241
+ this._model.updateWriters([{ user: this._persona }]);
242
+ try {
243
+ let content: UserContent = body;
244
+ if (attachments && attachments.length > 0) {
245
+ const providerConfig = this._settingsModel.getProvider(
246
+ this._agent.activeProvider
247
+ );
248
+ content = await processAttachments(
249
+ attachments,
250
+ this._documentManager,
251
+ body,
252
+ modelSupportsImages(providerConfig, this._providerRegistry),
253
+ modelSupportsPdf(providerConfig, this._providerRegistry),
254
+ modelSupportsAudio(providerConfig, this._providerRegistry)
255
+ );
256
+ }
257
+ await this._agent.generateResponse(content);
258
+ } catch (error) {
259
+ console.error('Persona: error generating response', error);
260
+ } finally {
261
+ this._busy = false;
262
+ this._busyChanged.emit(false);
263
+ this._model.updateWriters([]);
264
+ }
265
+ }
266
+
267
+ rebuildHistory(): Promise<void> {
268
+ return this._rebuildHistory();
269
+ }
270
+
271
+ private _onActiveProviderChanged(): void {
272
+ const providerConfig = this._settingsModel.getProvider(
273
+ this._agent.activeProvider
274
+ );
275
+ const modelKey = providerConfig
276
+ ? `${providerConfig.provider}:${providerConfig.model}`
277
+ : undefined;
278
+ if (modelKey && modelKey !== this._currentModelKey) {
279
+ this._currentModelKey = modelKey;
280
+ this._rebuildHistory().catch(e =>
281
+ console.warn('Failed to rebuild history on model change:', e)
282
+ );
283
+ }
284
+ }
285
+
286
+ private async _rebuildHistory(): Promise<void> {
287
+ const providerConfig = this._settingsModel.getProvider(
288
+ this._agent.activeProvider
289
+ );
290
+ const supportsImages = modelSupportsImages(
291
+ providerConfig,
292
+ this._providerRegistry
293
+ );
294
+ const supportsPdf = modelSupportsPdf(
295
+ providerConfig,
296
+ this._providerRegistry
297
+ );
298
+ const supportsAudio = modelSupportsAudio(
299
+ providerConfig,
300
+ this._providerRegistry
301
+ );
302
+
303
+ const modelMessages: ModelMessage[] = [];
304
+ for (const msg of this._model.messages) {
305
+ const isAI = msg.sender.bot === true;
306
+ if (!isAI && msg.attachments?.length) {
307
+ const enhancedContent = await processAttachments(
308
+ msg.attachments,
309
+ this._documentManager,
310
+ msg.body,
311
+ supportsImages,
312
+ supportsPdf,
313
+ supportsAudio
314
+ );
315
+ modelMessages.push({ role: 'user', content: enhancedContent });
316
+ } else if (msg.body) {
317
+ modelMessages.push({
318
+ role: isAI ? 'assistant' : 'user',
319
+ content: msg.body
320
+ });
321
+ }
322
+ }
323
+
324
+ this._agent.setHistory(modelMessages);
325
+ }
326
+
327
+ private _onAgentEvent(
328
+ _: IAgentManager,
329
+ event: IAgentManager.IAgentEvent
330
+ ): void {
331
+ switch (event.type) {
332
+ case 'message_start':
333
+ this._handleMessageStart(event);
334
+ break;
335
+ case 'message_chunk':
336
+ this._handleMessageChunk(event);
337
+ break;
338
+ case 'message_complete':
339
+ this._handleMessageComplete(event);
340
+ break;
341
+ case 'tool_call_start':
342
+ this._handleToolCallStart(event);
343
+ break;
344
+ case 'tool_call_complete':
345
+ this._handleToolCallComplete(event);
346
+ break;
347
+ case 'tool_approval_request':
348
+ this._handleToolApprovalRequest(event);
349
+ break;
350
+ case 'tool_approval_resolved':
351
+ this._handleToolApprovalResolved(event);
352
+ break;
353
+ case 'error':
354
+ this._handleError(event);
355
+ break;
356
+ }
357
+ }
358
+
359
+ private async _handleMessageStart(
360
+ event: IAgentManager.IAgentEvent<'message_start'>
361
+ ): Promise<void> {
362
+ const message: INewMessage = {
363
+ body: '',
364
+ sender: this._persona
365
+ };
366
+ const msgId = await this._model.sendMessage(message);
367
+ const streamingMessage =
368
+ this._model.messages.find(m => m.id === msgId) ?? null;
369
+
370
+ if (streamingMessage) {
371
+ this._streamingMessage.set(event.data.messageId, streamingMessage);
372
+ }
373
+ }
374
+
375
+ private _handleMessageChunk(
376
+ event: IAgentManager.IAgentEvent<'message_chunk'>
377
+ ): void {
378
+ const streamingMessage = this._streamingMessage.get(event.data.messageId);
379
+ if (streamingMessage) {
380
+ if (!this._model.updateMessage) {
381
+ streamingMessage.update({ body: event.data.fullContent });
382
+ } else {
383
+ this._model.updateMessage(streamingMessage.id, {
384
+ ...streamingMessage.content,
385
+ body: event.data.fullContent
386
+ });
387
+ }
388
+ }
389
+ }
390
+
391
+ private _handleMessageComplete(
392
+ event: IAgentManager.IAgentEvent<'message_complete'>
393
+ ): void {
394
+ const streamingMessage = this._streamingMessage.get(event.data.messageId);
395
+ if (streamingMessage) {
396
+ if (!this._model.updateMessage) {
397
+ streamingMessage.update({ body: event.data.content });
398
+ } else {
399
+ this._model.updateMessage(streamingMessage.id, {
400
+ ...streamingMessage.content,
401
+ body: event.data.content
402
+ });
403
+ }
404
+
405
+ this._streamingMessage.delete(event.data.messageId);
406
+ }
407
+ }
408
+
409
+ private async _handleToolCallStart(
410
+ event: IAgentManager.IAgentEvent<'tool_call_start'>
411
+ ): Promise<void> {
412
+ const summary = extractToolSummary(event.data.toolName, event.data.input);
413
+ const shouldAutoRenderMimeBundles =
414
+ this._computeShouldAutoRenderMimeBundles(
415
+ event.data.toolName,
416
+ event.data.input
417
+ );
418
+ const context: IToolExecutionContext = {
419
+ toolCallId: event.data.callId,
420
+ messageId: '',
421
+ toolName: event.data.toolName,
422
+ title: event.data.title,
423
+ input: event.data.input,
424
+ status: 'pending',
425
+ summary,
426
+ shouldAutoRenderMimeBundles
427
+ };
428
+
429
+ const displayName = context.title ?? context.toolName;
430
+ const messageId = await this._model.sendMessage({
431
+ body: '',
432
+ mime_model: {
433
+ data: {
434
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
435
+ },
436
+ metadata: {
437
+ toolCalls: [
438
+ {
439
+ toolCallId: context.toolCallId,
440
+ title: context.summary
441
+ ? `${displayName} : ${context.summary}`
442
+ : displayName,
443
+ kind: context.toolName,
444
+ status: 'in_progress',
445
+ rawInput: context.input
446
+ }
447
+ ]
448
+ }
449
+ },
450
+ sender: this._persona
451
+ });
452
+
453
+ if (messageId) {
454
+ context.messageId = messageId;
455
+ this._toolContexts.set(event.data.callId, context);
456
+ }
457
+ }
458
+
459
+ private _handleToolCallComplete(
460
+ event: IAgentManager.IAgentEvent<'tool_call_complete'>
461
+ ): void {
462
+ const context = this._toolContexts.get(event.data.callId);
463
+ const status = event.data.isError ? 'error' : 'completed';
464
+ this._updateToolCallUI(
465
+ event.data.callId,
466
+ status,
467
+ formatToolOutput(event.data.outputData)
468
+ );
469
+
470
+ if (!event.data.isError && context?.shouldAutoRenderMimeBundles) {
471
+ const trustedMimeTypes = new Set(
472
+ this._settingsModel.config.trustedMimeTypesForAutoRender
473
+ );
474
+ for (const bundle of extractMimeBundles(
475
+ event.data.outputData,
476
+ trustedMimeTypes
477
+ )) {
478
+ this._model.sendMessage({
479
+ body: '',
480
+ mime_model: bundle,
481
+ sender: this._persona
482
+ });
483
+ }
484
+ }
485
+
486
+ this._toolContexts.delete(event.data.callId);
487
+ }
488
+
489
+ private _computeShouldAutoRenderMimeBundles(
490
+ toolName: string,
491
+ input: string
492
+ ): boolean {
493
+ if (toolName !== 'execute_command') {
494
+ return false;
495
+ }
496
+ try {
497
+ const parsed = JSON.parse(input);
498
+ return (
499
+ typeof parsed.commandId === 'string' &&
500
+ this._settingsModel.config.commandsAutoRenderMimeBundles.includes(
501
+ parsed.commandId
502
+ )
503
+ );
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ private _handleToolApprovalRequest(
510
+ event: IAgentManager.IAgentEvent<'tool_approval_request'>
511
+ ): void {
512
+ const context = this._toolContexts.get(event.data.toolCallId);
513
+ if (!context) {
514
+ return;
515
+ }
516
+ context.input = JSON.stringify(event.data.args, null, 2);
517
+ this._updateToolCallUI(event.data.toolCallId, 'awaiting_approval');
518
+ }
519
+
520
+ private _handleToolApprovalResolved(
521
+ event: IAgentManager.IAgentEvent<'tool_approval_resolved'>
522
+ ): void {
523
+ const context = this._toolContexts.get(event.data.toolCallId);
524
+ if (!context) {
525
+ return;
526
+ }
527
+ const status = event.data.approved ? 'approved' : 'rejected';
528
+ this._updateToolCallUI(event.data.toolCallId, status);
529
+ if (!event.data.approved) {
530
+ this._toolContexts.delete(event.data.toolCallId);
531
+ }
532
+ }
533
+
534
+ private _handleError(event: IAgentManager.IAgentEvent<'error'>): void {
535
+ this._model.sendMessage({
536
+ body: '',
537
+ mime_model: {
538
+ data: { 'application/vnd.jupyter.chat.components': 'error' },
539
+ metadata: {
540
+ errorMessage: `Error generating response: ${event.data.error.message}`
541
+ }
542
+ },
543
+ sender: this._persona
544
+ });
545
+ }
546
+
547
+ private _updateToolCallUI(
548
+ toolCallId: string,
549
+ status: ToolStatus,
550
+ output?: string
551
+ ): void {
552
+ const context = this._toolContexts.get(toolCallId);
553
+ if (!context) {
554
+ return;
555
+ }
556
+ const message = this._model.messages.find(m => m.id === context.messageId);
557
+ if (!message) {
558
+ return;
559
+ }
560
+ context.status = status;
561
+ const displayName = context.title ?? context.toolName;
562
+ const mime_model = {
563
+ data: {
564
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
565
+ },
566
+ metadata: {
567
+ toolCalls: [
568
+ {
569
+ toolCallId: context.toolCallId,
570
+ title: context.summary
571
+ ? `${displayName} : ${context.summary}`
572
+ : displayName,
573
+ kind: context.toolName,
574
+ status: context.status,
575
+ rawInput: context.input,
576
+ rawOutput: output,
577
+ sessionId: this._model.name,
578
+ permissionStatus:
579
+ status === 'awaiting_approval' ? 'pending' : 'resolved',
580
+ ...(status === 'awaiting_approval' && {
581
+ permissionOptions: [
582
+ { optionId: 'approve', name: 'Approve', kind: 'allow_once' },
583
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' }
584
+ ]
585
+ })
586
+ }
587
+ ]
588
+ }
589
+ };
590
+ if (!this._model.updateMessage) {
591
+ message.update({ mime_model });
592
+ } else {
593
+ this._model.updateMessage(message.id, {
594
+ ...message.content,
595
+ mime_model
596
+ });
597
+ }
598
+ }
599
+
600
+ sendSystemMessage(body: string): void {
601
+ this._model.sendMessage({
602
+ body,
603
+ sender: this._persona
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Whether a mention is required to trigger a response.
609
+ * When false, the persona responds to all non-bot messages.
610
+ * Defaults to true.
611
+ */
612
+ requireMention: boolean = true;
613
+
614
+ private readonly _model: IChatModel;
615
+ private readonly _agent: IAgentManager;
616
+ private readonly _persona: IUser;
617
+ private readonly _settingsModel: IAISettingsModel;
618
+ private readonly _providerRegistry: IProviderRegistry | undefined;
619
+ private readonly _documentManager: IDocumentManager | undefined;
620
+ private _respondedToIds = new Set<string>();
621
+ private _currentModelKey: string | undefined;
622
+ private _busy = false;
623
+ private _busyChanged = new Signal<IPersona, boolean>(this);
624
+ private _streamingMessage = new Map<string, IMessage>();
625
+ private _toolContexts = new Map<string, IToolExecutionContext>();
626
+ }
627
+
628
+ export namespace Persona {
629
+ export interface IOptions {
630
+ model: IChatModel;
631
+ agentManager: IAgentManager;
632
+ persona: IUser;
633
+ settingsModel: IAISettingsModel;
634
+ providerRegistry?: IProviderRegistry;
635
+ documentManager?: IDocumentManager;
636
+ }
637
+ }