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