@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/lib/persona.js ADDED
@@ -0,0 +1,414 @@
1
+ import * as nbformat from '@jupyterlab/nbformat';
2
+ import { modelSupportsAudio, modelSupportsImages, modelSupportsPdf } from '@jupyternaut/agent';
3
+ import { Signal } from '@lumino/signaling';
4
+ import { processAttachments } from './process-attachments';
5
+ function extractToolSummary(toolName, input) {
6
+ try {
7
+ const parsed = JSON.parse(input);
8
+ switch (toolName) {
9
+ case 'execute_command':
10
+ return parsed.commandId ?? '';
11
+ case 'discover_commands':
12
+ case 'discover_skills':
13
+ case 'web_search':
14
+ return parsed.query ? `query: "${parsed.query}"` : '';
15
+ case 'load_skill':
16
+ return parsed.name
17
+ ? parsed.resource
18
+ ? `${parsed.name} (${parsed.resource})`
19
+ : parsed.name
20
+ : '';
21
+ case 'browser_fetch':
22
+ case 'web_fetch':
23
+ return parsed.url ?? '';
24
+ }
25
+ }
26
+ catch {
27
+ // ignore malformed input
28
+ }
29
+ return '';
30
+ }
31
+ function formatToolOutput(outputData) {
32
+ if (typeof outputData === 'string') {
33
+ return outputData;
34
+ }
35
+ try {
36
+ return JSON.stringify(outputData, null, 2);
37
+ }
38
+ catch {
39
+ return '[Complex object - cannot serialize]';
40
+ }
41
+ }
42
+ function isPlainObject(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
44
+ }
45
+ function isDisplayOutput(value) {
46
+ if (!isPlainObject(value)) {
47
+ return false;
48
+ }
49
+ const output = value;
50
+ return (nbformat.isDisplayData(output) ||
51
+ nbformat.isDisplayUpdate(output) ||
52
+ nbformat.isExecuteResult(output));
53
+ }
54
+ function toDisplayOutputs(value) {
55
+ if (isDisplayOutput(value)) {
56
+ return [value];
57
+ }
58
+ if (Array.isArray(value)) {
59
+ return value.filter(isDisplayOutput);
60
+ }
61
+ if (!isPlainObject(value)) {
62
+ return [];
63
+ }
64
+ if (Array.isArray(value.outputs)) {
65
+ return value.outputs.filter(isDisplayOutput);
66
+ }
67
+ if ('result' in value) {
68
+ return toDisplayOutputs(value.result);
69
+ }
70
+ return [];
71
+ }
72
+ function extractMimeBundles(content, trustedMimeTypes) {
73
+ return toDisplayOutputs(content)
74
+ .map((output) => {
75
+ const data = output.data;
76
+ if (!isPlainObject(data) || Object.keys(data).length === 0) {
77
+ return null;
78
+ }
79
+ return {
80
+ data: data,
81
+ ...(isPlainObject(output.metadata)
82
+ ? {
83
+ metadata: output.metadata
84
+ }
85
+ : {}),
86
+ ...(Object.keys(data).some(m => trustedMimeTypes.has(m))
87
+ ? { trusted: true }
88
+ : {})
89
+ };
90
+ })
91
+ .filter((b) => b !== null);
92
+ }
93
+ /**
94
+ * Links an IAgentManager to an IChatModel for the Jupyternaut persona.
95
+ *
96
+ * Monitors new messages arriving on the chat model and responds when the
97
+ * persona trigger string is mentioned. The handler and its agent stay alive
98
+ * as long as the associated chat widget is open, so conversation history is
99
+ * preserved across multiple mentions.
100
+ */
101
+ export class Persona {
102
+ constructor(options) {
103
+ this._model = options.model;
104
+ this._agent = options.agentManager;
105
+ this._persona = options.persona;
106
+ this._settingsModel = options.settingsModel;
107
+ this._providerRegistry = options.providerRegistry;
108
+ this._documentManager = options.documentManager;
109
+ for (const message of options.model.messages) {
110
+ this._respondedToIds.add(message.id);
111
+ }
112
+ this._agent.agentEvent.connect(this._onAgentEvent, this);
113
+ this._agent.activeProviderChanged.connect(this._onActiveProviderChanged, this);
114
+ this._model.messagesUpdated.connect(this._onMessagesUpdated, this);
115
+ this._model.disposed.connect(this.dispose, this);
116
+ }
117
+ dispose() {
118
+ this._agent.agentEvent.disconnect(this._onAgentEvent, this);
119
+ this._agent.activeProviderChanged.disconnect(this._onActiveProviderChanged, this);
120
+ this._model.messagesUpdated.disconnect(this._onMessagesUpdated, this);
121
+ }
122
+ get agentManager() {
123
+ return this._agent;
124
+ }
125
+ get model() {
126
+ return this._model;
127
+ }
128
+ get isBusy() {
129
+ return this._busy;
130
+ }
131
+ get busyChanged() {
132
+ return this._busyChanged;
133
+ }
134
+ async _onMessagesUpdated() {
135
+ const unhandled = this._model.messages.filter(m => !this._respondedToIds.has(m.id) &&
136
+ !m.sender.bot &&
137
+ (!this.requireMention || m.mentions?.includes(this._persona)));
138
+ for (const message of unhandled) {
139
+ this._respondedToIds.add(message.id);
140
+ }
141
+ for (const message of unhandled) {
142
+ const personaMention = `@${this._persona.mention_name}`;
143
+ const body = message.body.replace(personaMention, '').trim();
144
+ await this._respond(body || message.body, message.attachments);
145
+ }
146
+ }
147
+ async _respond(body, attachments) {
148
+ this._busy = true;
149
+ this._busyChanged.emit(true);
150
+ this._model.updateWriters([{ user: this._persona }]);
151
+ try {
152
+ let content = body;
153
+ if (attachments && attachments.length > 0) {
154
+ const providerConfig = this._settingsModel.getProvider(this._agent.activeProvider);
155
+ content = await processAttachments(attachments, this._documentManager, body, modelSupportsImages(providerConfig, this._providerRegistry), modelSupportsPdf(providerConfig, this._providerRegistry), modelSupportsAudio(providerConfig, this._providerRegistry));
156
+ }
157
+ await this._agent.generateResponse(content);
158
+ }
159
+ catch (error) {
160
+ console.error('Persona: error generating response', error);
161
+ }
162
+ finally {
163
+ this._busy = false;
164
+ this._busyChanged.emit(false);
165
+ this._model.updateWriters([]);
166
+ }
167
+ }
168
+ rebuildHistory() {
169
+ return this._rebuildHistory();
170
+ }
171
+ _onActiveProviderChanged() {
172
+ const providerConfig = this._settingsModel.getProvider(this._agent.activeProvider);
173
+ const modelKey = providerConfig
174
+ ? `${providerConfig.provider}:${providerConfig.model}`
175
+ : undefined;
176
+ if (modelKey && modelKey !== this._currentModelKey) {
177
+ this._currentModelKey = modelKey;
178
+ this._rebuildHistory().catch(e => console.warn('Failed to rebuild history on model change:', e));
179
+ }
180
+ }
181
+ async _rebuildHistory() {
182
+ const providerConfig = this._settingsModel.getProvider(this._agent.activeProvider);
183
+ const supportsImages = modelSupportsImages(providerConfig, this._providerRegistry);
184
+ const supportsPdf = modelSupportsPdf(providerConfig, this._providerRegistry);
185
+ const supportsAudio = modelSupportsAudio(providerConfig, this._providerRegistry);
186
+ const modelMessages = [];
187
+ for (const msg of this._model.messages) {
188
+ const isAI = msg.sender.bot === true;
189
+ if (!isAI && msg.attachments?.length) {
190
+ const enhancedContent = await processAttachments(msg.attachments, this._documentManager, msg.body, supportsImages, supportsPdf, supportsAudio);
191
+ modelMessages.push({ role: 'user', content: enhancedContent });
192
+ }
193
+ else if (msg.body) {
194
+ modelMessages.push({
195
+ role: isAI ? 'assistant' : 'user',
196
+ content: msg.body
197
+ });
198
+ }
199
+ }
200
+ this._agent.setHistory(modelMessages);
201
+ }
202
+ _onAgentEvent(_, event) {
203
+ switch (event.type) {
204
+ case 'message_start':
205
+ this._handleMessageStart(event);
206
+ break;
207
+ case 'message_chunk':
208
+ this._handleMessageChunk(event);
209
+ break;
210
+ case 'message_complete':
211
+ this._handleMessageComplete(event);
212
+ break;
213
+ case 'tool_call_start':
214
+ this._handleToolCallStart(event);
215
+ break;
216
+ case 'tool_call_complete':
217
+ this._handleToolCallComplete(event);
218
+ break;
219
+ case 'tool_approval_request':
220
+ this._handleToolApprovalRequest(event);
221
+ break;
222
+ case 'tool_approval_resolved':
223
+ this._handleToolApprovalResolved(event);
224
+ break;
225
+ case 'error':
226
+ this._handleError(event);
227
+ break;
228
+ }
229
+ }
230
+ async _handleMessageStart(event) {
231
+ const message = {
232
+ body: '',
233
+ sender: this._persona
234
+ };
235
+ const msgId = await this._model.sendMessage(message);
236
+ const streamingMessage = this._model.messages.find(m => m.id === msgId) ?? null;
237
+ if (streamingMessage) {
238
+ this._streamingMessage.set(event.data.messageId, streamingMessage);
239
+ }
240
+ }
241
+ _handleMessageChunk(event) {
242
+ const streamingMessage = this._streamingMessage.get(event.data.messageId);
243
+ if (streamingMessage) {
244
+ streamingMessage.update({ body: event.data.fullContent });
245
+ }
246
+ }
247
+ _handleMessageComplete(event) {
248
+ const streamingMessage = this._streamingMessage.get(event.data.messageId);
249
+ if (streamingMessage) {
250
+ streamingMessage.update({ body: event.data.content });
251
+ this._streamingMessage.delete(event.data.messageId);
252
+ }
253
+ }
254
+ async _handleToolCallStart(event) {
255
+ const summary = extractToolSummary(event.data.toolName, event.data.input);
256
+ const shouldAutoRenderMimeBundles = this._computeShouldAutoRenderMimeBundles(event.data.toolName, event.data.input);
257
+ const context = {
258
+ toolCallId: event.data.callId,
259
+ messageId: '',
260
+ toolName: event.data.toolName,
261
+ title: event.data.title,
262
+ input: event.data.input,
263
+ status: 'pending',
264
+ summary,
265
+ shouldAutoRenderMimeBundles
266
+ };
267
+ const displayName = context.title ?? context.toolName;
268
+ const messageId = await this._model.sendMessage({
269
+ body: '',
270
+ mime_model: {
271
+ data: {
272
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
273
+ },
274
+ metadata: {
275
+ toolCalls: [
276
+ {
277
+ toolCallId: context.toolCallId,
278
+ title: context.summary
279
+ ? `${displayName} : ${context.summary}`
280
+ : displayName,
281
+ kind: context.toolName,
282
+ status: 'in_progress',
283
+ rawInput: context.input
284
+ }
285
+ ]
286
+ }
287
+ },
288
+ sender: this._persona
289
+ });
290
+ if (messageId) {
291
+ context.messageId = messageId;
292
+ this._toolContexts.set(event.data.callId, context);
293
+ }
294
+ }
295
+ _handleToolCallComplete(event) {
296
+ const context = this._toolContexts.get(event.data.callId);
297
+ const status = event.data.isError ? 'error' : 'completed';
298
+ this._updateToolCallUI(event.data.callId, status, formatToolOutput(event.data.outputData));
299
+ if (!event.data.isError && context?.shouldAutoRenderMimeBundles) {
300
+ const trustedMimeTypes = new Set(this._settingsModel.config.trustedMimeTypesForAutoRender);
301
+ for (const bundle of extractMimeBundles(event.data.outputData, trustedMimeTypes)) {
302
+ this._model.sendMessage({
303
+ body: '',
304
+ mime_model: bundle,
305
+ sender: this._persona
306
+ });
307
+ }
308
+ }
309
+ this._toolContexts.delete(event.data.callId);
310
+ }
311
+ _computeShouldAutoRenderMimeBundles(toolName, input) {
312
+ if (toolName !== 'execute_command') {
313
+ return false;
314
+ }
315
+ try {
316
+ const parsed = JSON.parse(input);
317
+ return (typeof parsed.commandId === 'string' &&
318
+ this._settingsModel.config.commandsAutoRenderMimeBundles.includes(parsed.commandId));
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ }
324
+ _handleToolApprovalRequest(event) {
325
+ const context = this._toolContexts.get(event.data.toolCallId);
326
+ if (!context) {
327
+ return;
328
+ }
329
+ context.input = JSON.stringify(event.data.args, null, 2);
330
+ this._updateToolCallUI(event.data.toolCallId, 'awaiting_approval');
331
+ }
332
+ _handleToolApprovalResolved(event) {
333
+ const context = this._toolContexts.get(event.data.toolCallId);
334
+ if (!context) {
335
+ return;
336
+ }
337
+ const status = event.data.approved ? 'approved' : 'rejected';
338
+ this._updateToolCallUI(event.data.toolCallId, status);
339
+ if (!event.data.approved) {
340
+ this._toolContexts.delete(event.data.toolCallId);
341
+ }
342
+ }
343
+ _handleError(event) {
344
+ this._model.sendMessage({
345
+ body: '',
346
+ mime_model: {
347
+ data: { 'application/vnd.jupyter.chat.components': 'error' },
348
+ metadata: {
349
+ errorMessage: `Error generating response: ${event.data.error.message}`
350
+ }
351
+ },
352
+ sender: this._persona
353
+ });
354
+ }
355
+ _updateToolCallUI(toolCallId, status, output) {
356
+ const context = this._toolContexts.get(toolCallId);
357
+ if (!context) {
358
+ return;
359
+ }
360
+ const message = this._model.messages.find(m => m.id === context.messageId);
361
+ if (!message) {
362
+ return;
363
+ }
364
+ context.status = status;
365
+ const displayName = context.title ?? context.toolName;
366
+ message.update({
367
+ mime_model: {
368
+ data: {
369
+ 'application/vnd.jupyter.chat.components': 'grouped-tool-calls'
370
+ },
371
+ metadata: {
372
+ toolCalls: [
373
+ {
374
+ toolCallId: context.toolCallId,
375
+ title: context.summary
376
+ ? `${displayName} : ${context.summary}`
377
+ : displayName,
378
+ kind: context.toolName,
379
+ status: context.status,
380
+ rawInput: context.input,
381
+ rawOutput: output,
382
+ sessionId: this._model.name,
383
+ permissionStatus: status === 'awaiting_approval' ? 'pending' : 'resolved',
384
+ ...(status === 'awaiting_approval' && {
385
+ permissionOptions: [
386
+ { optionId: 'approve', name: 'Approve', kind: 'allow_once' },
387
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' }
388
+ ]
389
+ })
390
+ }
391
+ ]
392
+ }
393
+ }
394
+ });
395
+ }
396
+ /**
397
+ * Whether a mention is required to trigger a response.
398
+ * When false, the persona responds to all non-bot messages.
399
+ * Defaults to true.
400
+ */
401
+ requireMention = true;
402
+ _model;
403
+ _agent;
404
+ _persona;
405
+ _settingsModel;
406
+ _providerRegistry;
407
+ _documentManager;
408
+ _respondedToIds = new Set();
409
+ _currentModelKey;
410
+ _busy = false;
411
+ _busyChanged = new Signal(this);
412
+ _streamingMessage = new Map();
413
+ _toolContexts = new Map();
414
+ }
@@ -0,0 +1,5 @@
1
+ import type { IAttachment } from '@jupyter/chat';
2
+ import type { IDocumentManager } from '@jupyterlab/docmanager';
3
+ import type { UserContent } from 'ai';
4
+ export declare function processAttachments(attachments: IAttachment[], documentManager: IDocumentManager | null | undefined, body: string, supportsImages: boolean, supportsPdf: boolean, supportsAudio: boolean): Promise<UserContent>;
5
+ //# sourceMappingURL=process-attachments.d.ts.map