@mtharrison/loupe 1.4.0 → 1.5.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.
package/dist/index.js CHANGED
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.isTraceEnabled = isTraceEnabled;
4
37
  exports.getLocalLLMTracer = getLocalLLMTracer;
@@ -11,6 +44,7 @@ exports.__resetLocalLLMTracerForTests = __resetLocalLLMTracerForTests;
11
44
  exports.wrapChatModel = wrapChatModel;
12
45
  exports.wrapOpenAIClient = wrapOpenAIClient;
13
46
  const node_async_hooks_1 = require("node:async_hooks");
47
+ const childProcess = __importStar(require("node:child_process"));
14
48
  const server_1 = require("./server");
15
49
  const store_1 = require("./store");
16
50
  const ui_build_1 = require("./ui-build");
@@ -19,7 +53,10 @@ let singleton = null;
19
53
  const DEFAULT_TRACE_PORT = 4319;
20
54
  const activeSpanStorage = new node_async_hooks_1.AsyncLocalStorage();
21
55
  function isTraceEnabled() {
22
- return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
56
+ if (process.env.LLM_TRACE_ENABLED !== undefined) {
57
+ return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
58
+ }
59
+ return process.env.NODE_ENV === 'development';
23
60
  }
24
61
  function getLocalLLMTracer(config = {}) {
25
62
  if (!singleton) {
@@ -180,6 +217,7 @@ function wrapOpenAIClient(client, getContext, config) {
180
217
  class LocalLLMTracerImpl {
181
218
  config;
182
219
  loggedUrl;
220
+ openedBrowser;
183
221
  portWasExplicit;
184
222
  server;
185
223
  serverFailed;
@@ -202,6 +240,7 @@ class LocalLLMTracerImpl {
202
240
  this.serverStartPromise = null;
203
241
  this.serverFailed = false;
204
242
  this.loggedUrl = false;
243
+ this.openedBrowser = false;
205
244
  this.uiWatcher = null;
206
245
  }
207
246
  configure(config = {}) {
@@ -278,6 +317,10 @@ class LocalLLMTracerImpl {
278
317
  this.loggedUrl = true;
279
318
  process.stdout.write(`[llm-trace] dashboard: ${this.serverInfo.url}\n`);
280
319
  }
320
+ if (!this.openedBrowser && this.serverInfo && shouldAutoOpenDashboard()) {
321
+ this.openedBrowser = true;
322
+ openBrowser(this.serverInfo.url);
323
+ }
281
324
  return this.serverInfo;
282
325
  }
283
326
  catch (error) {
@@ -292,6 +335,37 @@ class LocalLLMTracerImpl {
292
335
  return this.serverStartPromise;
293
336
  }
294
337
  }
338
+ function shouldAutoOpenDashboard() {
339
+ if (process.env.LOUPE_OPEN_BROWSER === '0') {
340
+ return false;
341
+ }
342
+ return (process.env.NODE_ENV === 'development'
343
+ && !process.env.CI
344
+ && !!process.stdout.isTTY);
345
+ }
346
+ function openBrowser(url) {
347
+ const command = process.platform === 'darwin'
348
+ ? ['open', [url]]
349
+ : process.platform === 'win32'
350
+ ? ['cmd', ['/c', 'start', '', url]]
351
+ : process.platform === 'linux'
352
+ ? ['xdg-open', [url]]
353
+ : null;
354
+ if (!command) {
355
+ return;
356
+ }
357
+ try {
358
+ const child = childProcess.spawn(command[0], command[1], {
359
+ detached: true,
360
+ stdio: 'ignore',
361
+ });
362
+ child.on('error', () => { });
363
+ child.unref();
364
+ }
365
+ catch (_error) {
366
+ // Ignore browser launch failures. The dashboard URL is already printed.
367
+ }
368
+ }
295
369
  function normaliseRequest(request) {
296
370
  return {
297
371
  input: (0, utils_1.safeClone)(request?.input),
package/dist/utils.js CHANGED
@@ -134,7 +134,9 @@ function normalizeTraceContext(context, mode) {
134
134
  const explicitKind = normalizeKind(raw.kind);
135
135
  let kind = explicitKind || 'actor';
136
136
  if (!explicitKind) {
137
- if (guardrailType || guardrailPhase) {
137
+ // Keep generic guardrail metadata from masking actual delegated-agent spans.
138
+ // Explicit guardrail spans or spans with a concrete phase still remain guardrails.
139
+ if (guardrailPhase || (guardrailType && !isChildActor)) {
138
140
  kind = 'guardrail';
139
141
  }
140
142
  else if (stage) {
@@ -0,0 +1,533 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const { randomUUID } = require('node:crypto');
5
+ const readline = require('node:readline/promises');
6
+ const process = require('node:process');
7
+
8
+ const {
9
+ addSpanEvent,
10
+ endSpan,
11
+ getLocalLLMTracer,
12
+ recordException,
13
+ startSpan,
14
+ wrapChatModel,
15
+ } = require('../dist/index.js');
16
+
17
+ const DEMO_TIMEOUT_MS = 15000;
18
+
19
+ const GUIDE_DATA = {
20
+ kyoto: {
21
+ coffee: ['Weekenders Coffee Tominokoji', 'Kurasu Kyoto Stand'],
22
+ museum: 'Kyoto National Museum',
23
+ neighborhood: 'Gion',
24
+ pack: ['layers', 'comfortable walking shoes', 'a compact umbrella'],
25
+ walk: 'Philosopher\'s Path and Higashiyama backstreets',
26
+ },
27
+ lisbon: {
28
+ coffee: ['Hello, Kristof', 'The Folks'],
29
+ museum: 'MAAT',
30
+ neighborhood: 'Baixa and Chiado',
31
+ pack: ['layers', 'comfortable walking shoes', 'a light rain shell'],
32
+ walk: 'Alfama viewpoints loop',
33
+ },
34
+ };
35
+
36
+ async function runFullyFeaturedExample(options = {}) {
37
+ process.env.LLM_TRACE_ENABLED ??= '1';
38
+
39
+ const destinationKey = normalizeDestinationKey(options.destination || 'Lisbon');
40
+ const guide = GUIDE_DATA[destinationKey];
41
+ if (!guide) {
42
+ throw new Error(`Unsupported destination "${String(options.destination)}". Try Lisbon or Kyoto.`);
43
+ }
44
+
45
+ const port = getPort(options.port);
46
+ const destination = formatDestination(destinationKey);
47
+ const sessionId = options.sessionId || `fully-featured-demo-${randomUUID().slice(0, 8)}`;
48
+ const keepAlive = options.keepAlive !== false;
49
+ const openBrowserEnabled = options.openBrowser !== false;
50
+ const tracer = getLocalLLMTracer({ port });
51
+ const serverInfo = await tracer.startServer();
52
+
53
+ if (!serverInfo?.url) {
54
+ throw new Error('Failed to start the Loupe dashboard.');
55
+ }
56
+
57
+ log(`[demo] Loupe dashboard: ${serverInfo.url}`);
58
+ if (openBrowserEnabled) {
59
+ openBrowser(serverInfo.url);
60
+ }
61
+
62
+ log(`[demo] Session: ${sessionId}`);
63
+ log(`[demo] Destination: ${destination}`);
64
+
65
+ const baseTags = {
66
+ destination,
67
+ example: 'fully-featured',
68
+ workflow: 'weekend-planner',
69
+ };
70
+
71
+ const buildContext = (overrides = {}) => {
72
+ const { tags = {}, ...rest } = overrides;
73
+ return {
74
+ sessionId,
75
+ rootSessionId: sessionId,
76
+ rootActorId: 'travel-assistant',
77
+ actorId: 'travel-assistant',
78
+ actorType: 'assistant',
79
+ model: 'trip-planner-v2',
80
+ provider: 'mock-llm',
81
+ tags: {
82
+ ...baseTags,
83
+ ...tags,
84
+ },
85
+ tenantId: 'demo-travel',
86
+ userId: 'traveler-01',
87
+ ...rest,
88
+ };
89
+ };
90
+
91
+ const researchModel = wrapChatModel(
92
+ {
93
+ async invoke(input) {
94
+ const requestedDestination = formatDestination(normalizeDestinationKey(input?.destination || destination));
95
+ const selectedGuide = GUIDE_DATA[normalizeDestinationKey(requestedDestination)] || guide;
96
+ return {
97
+ message: {
98
+ role: 'assistant',
99
+ content: [
100
+ `Base the trip around ${selectedGuide.neighborhood}.`,
101
+ `Coffee: ${selectedGuide.coffee.join(' and ')}.`,
102
+ `Walk: ${selectedGuide.walk}.`,
103
+ `Museum: ${selectedGuide.museum}.`,
104
+ ].join(' '),
105
+ },
106
+ tool_calls: [],
107
+ usage: createUsage(18, 27),
108
+ };
109
+ },
110
+ async *stream(input) {
111
+ const requestedDestination = formatDestination(normalizeDestinationKey(input?.destination || destination));
112
+ const selectedGuide = GUIDE_DATA[normalizeDestinationKey(requestedDestination)] || guide;
113
+ const content = `Research highlights for ${requestedDestination}: ${selectedGuide.walk}, ${selectedGuide.museum}.`;
114
+ yield { type: 'begin', role: 'assistant' };
115
+ yield { type: 'chunk', content };
116
+ yield {
117
+ type: 'finish',
118
+ message: { role: 'assistant', content },
119
+ tool_calls: [],
120
+ usage: createUsage(10, 12),
121
+ };
122
+ },
123
+ },
124
+ () =>
125
+ buildContext({
126
+ actorId: 'city-researcher',
127
+ actorType: 'tool',
128
+ model: 'city-researcher-v1',
129
+ tags: {
130
+ surface: 'research',
131
+ },
132
+ }),
133
+ { port },
134
+ );
135
+
136
+ const plannerModel = wrapChatModel(
137
+ {
138
+ async invoke(input) {
139
+ const researchStageId = startSpan(
140
+ buildContext({
141
+ stage: 'research',
142
+ tags: {
143
+ surface: 'research-stage',
144
+ },
145
+ }),
146
+ {
147
+ mode: 'invoke',
148
+ name: 'workflow.research',
149
+ request: {
150
+ input: {
151
+ destination: input.destination,
152
+ interests: input.interests,
153
+ },
154
+ options: {
155
+ sources: ['city-guide', 'weather-notes'],
156
+ },
157
+ },
158
+ },
159
+ { port },
160
+ );
161
+
162
+ addSpanEvent(
163
+ researchStageId,
164
+ {
165
+ name: 'retrieval.hit',
166
+ attributes: {
167
+ guide: destination,
168
+ sources: 2,
169
+ },
170
+ payload: {
171
+ guide: destination,
172
+ sources: ['city-guide', 'weather-notes'],
173
+ },
174
+ },
175
+ { port },
176
+ );
177
+
178
+ endSpan(
179
+ researchStageId,
180
+ {
181
+ summary: `Loaded local guide data for ${destination}.`,
182
+ usage: createUsage(6, 4, 0.0000005, 0.0000008),
183
+ },
184
+ { port },
185
+ );
186
+
187
+ const research = await researchModel.invoke(
188
+ {
189
+ destination: input.destination,
190
+ interests: input.interests,
191
+ messages: [
192
+ {
193
+ role: 'user',
194
+ content: `Research a two-day ${destination} itinerary for ${input.interests.join(', ')}.`,
195
+ },
196
+ ],
197
+ },
198
+ {
199
+ channel: 'research',
200
+ },
201
+ );
202
+
203
+ const availabilitySpanId = startSpan(
204
+ buildContext({
205
+ actorId: 'availability-service',
206
+ actorType: 'service',
207
+ tags: {
208
+ surface: 'availability',
209
+ },
210
+ }),
211
+ {
212
+ mode: 'invoke',
213
+ name: 'tool.availability-check',
214
+ request: {
215
+ input: {
216
+ destination: input.destination,
217
+ hotel: `${destination} Riverside House`,
218
+ },
219
+ options: {
220
+ timeoutMs: 250,
221
+ },
222
+ },
223
+ },
224
+ { port },
225
+ );
226
+
227
+ try {
228
+ throw Object.assign(new Error('Supplier timed out during live availability lookup.'), {
229
+ code: 'SUPPLIER_TIMEOUT',
230
+ status: 504,
231
+ });
232
+ } catch (error) {
233
+ // Keep the demo moving while leaving one child span in the error state.
234
+ recordException(availabilitySpanId, error, { port });
235
+ }
236
+
237
+ const outputGuardrailId = startSpan(
238
+ buildContext({
239
+ guardrailType: 'output-policy',
240
+ tags: {
241
+ surface: 'output-guardrail',
242
+ },
243
+ }),
244
+ {
245
+ mode: 'invoke',
246
+ name: 'guardrail.output',
247
+ request: {
248
+ input: {
249
+ draft: research.message.content,
250
+ },
251
+ options: {
252
+ policy: 'travel-safe',
253
+ },
254
+ },
255
+ },
256
+ { port },
257
+ );
258
+
259
+ addSpanEvent(
260
+ outputGuardrailId,
261
+ {
262
+ name: 'guardrail.review',
263
+ attributes: {
264
+ outcome: 'pass',
265
+ policy: 'travel-safe',
266
+ },
267
+ payload: {
268
+ removedClaims: 0,
269
+ },
270
+ },
271
+ { port },
272
+ );
273
+
274
+ endSpan(
275
+ outputGuardrailId,
276
+ {
277
+ reason: 'Advice stays within local-demo safety rules.',
278
+ result: 'pass',
279
+ },
280
+ { port },
281
+ );
282
+
283
+ return {
284
+ message: {
285
+ role: 'assistant',
286
+ content: [
287
+ `Day 1: start in ${guide.neighborhood} with ${guide.coffee[0]}, then walk ${guide.walk}.`,
288
+ `Day 2: visit ${guide.museum}, then use ${guide.coffee[1]} as a reset stop.`,
289
+ research.message.content,
290
+ 'One live availability lookup failed, so confirm bookings directly before you go.',
291
+ `Pack ${guide.pack.join(', ')}.`,
292
+ ].join('\n'),
293
+ },
294
+ tool_calls: [],
295
+ usage: createUsage(42, 68),
296
+ };
297
+ },
298
+ async *stream(input) {
299
+ const parts = [
300
+ `Day 1 in ${destination}: ${guide.coffee[0]}, then ${guide.walk}. `,
301
+ `Day 2: ${guide.museum}, followed by time around ${guide.neighborhood}. `,
302
+ `Pack ${guide.pack.join(', ')}.`,
303
+ ];
304
+ const finalContent = parts.join('');
305
+
306
+ yield { type: 'begin', role: 'assistant' };
307
+ for (const content of parts) {
308
+ yield { type: 'chunk', content };
309
+ }
310
+ yield {
311
+ type: 'finish',
312
+ message: { role: 'assistant', content: finalContent },
313
+ tool_calls: [],
314
+ usage: createUsage(24, 39),
315
+ };
316
+ },
317
+ },
318
+ () =>
319
+ buildContext({
320
+ tags: {
321
+ surface: 'planner',
322
+ },
323
+ }),
324
+ { port },
325
+ );
326
+
327
+ const invokeInput = {
328
+ days: 2,
329
+ destination,
330
+ interests: ['coffee', 'walking', 'museum'],
331
+ messages: [
332
+ {
333
+ role: 'user',
334
+ content: `Plan a safe two-day ${destination} trip with coffee, walking, and one museum stop.`,
335
+ },
336
+ ],
337
+ };
338
+
339
+ const inputGuardrailId = startSpan(
340
+ buildContext({
341
+ guardrailType: 'input-policy',
342
+ tags: {
343
+ surface: 'input-guardrail',
344
+ },
345
+ }),
346
+ {
347
+ mode: 'invoke',
348
+ name: 'guardrail.input',
349
+ request: {
350
+ input: invokeInput,
351
+ options: {
352
+ policy: 'travel-safe',
353
+ },
354
+ },
355
+ },
356
+ { port },
357
+ );
358
+
359
+ addSpanEvent(
360
+ inputGuardrailId,
361
+ {
362
+ name: 'guardrail.review',
363
+ attributes: {
364
+ outcome: 'pass',
365
+ policy: 'travel-safe',
366
+ },
367
+ payload: {
368
+ matchedRules: ['safe-travel'],
369
+ },
370
+ },
371
+ { port },
372
+ );
373
+
374
+ endSpan(
375
+ inputGuardrailId,
376
+ {
377
+ reason: 'The prompt is safe to answer.',
378
+ result: 'pass',
379
+ },
380
+ { port },
381
+ );
382
+
383
+ const invokeResponse = await plannerModel.invoke(invokeInput, {
384
+ channel: 'planning',
385
+ });
386
+
387
+ log('');
388
+ log('[demo] Invoke answer:');
389
+ log(invokeResponse.message.content);
390
+
391
+ const streamInput = {
392
+ ...invokeInput,
393
+ messages: [
394
+ {
395
+ role: 'user',
396
+ content: `Now stream the concise version of the ${destination} plan.`,
397
+ },
398
+ ],
399
+ };
400
+
401
+ const streamChunks = [];
402
+ for await (const chunk of plannerModel.stream(streamInput, { channel: 'handoff' })) {
403
+ streamChunks.push(chunk);
404
+ }
405
+
406
+ const streamReply = extractStreamText(streamChunks);
407
+ log('');
408
+ log('[demo] Stream answer:');
409
+ log(streamReply);
410
+
411
+ const traceCount = tracer.store
412
+ .list()
413
+ .items.filter((item) => item.hierarchy.sessionId === sessionId && item.tags.example === 'fully-featured').length;
414
+
415
+ log('');
416
+ log(`[demo] Recorded ${traceCount} traces for one session across guardrails, nested calls, errors, and streaming.`);
417
+
418
+ if (keepAlive) {
419
+ log(`[demo] Keep this process alive while you inspect ${serverInfo.url}`);
420
+ await waitForDashboardExit(serverInfo.url);
421
+ }
422
+
423
+ return {
424
+ invokeReply: invokeResponse.message.content,
425
+ sessionId,
426
+ streamReply,
427
+ traceCount,
428
+ url: serverInfo.url,
429
+ };
430
+ }
431
+
432
+ function createUsage(promptTokens, completionTokens, promptPrice = 0.000001, completionPrice = 0.000002) {
433
+ return {
434
+ pricing: {
435
+ completion: completionPrice,
436
+ prompt: promptPrice,
437
+ },
438
+ tokens: {
439
+ completion: completionTokens,
440
+ prompt: promptTokens,
441
+ },
442
+ };
443
+ }
444
+
445
+ function extractStreamText(chunks) {
446
+ return chunks
447
+ .filter((chunk) => chunk?.type === 'chunk' && typeof chunk.content === 'string')
448
+ .map((chunk) => chunk.content)
449
+ .join('');
450
+ }
451
+
452
+ function normalizeDestinationKey(value) {
453
+ return String(value || '')
454
+ .trim()
455
+ .toLowerCase();
456
+ }
457
+
458
+ function formatDestination(value) {
459
+ return String(value)
460
+ .split(' ')
461
+ .filter(Boolean)
462
+ .map((part) => part[0].toUpperCase() + part.slice(1))
463
+ .join(' ');
464
+ }
465
+
466
+ function getPort(port) {
467
+ const explicit = Number(port ?? process.env.LLM_TRACE_PORT);
468
+ return Number.isFinite(explicit) && explicit > 0 ? explicit : 4319;
469
+ }
470
+
471
+ function openBrowser(url) {
472
+ if (!process.stdout.isTTY || process.env.CI || process.env.LOUPE_OPEN_BROWSER === '0') {
473
+ return;
474
+ }
475
+
476
+ const command =
477
+ process.platform === 'darwin'
478
+ ? ['open', [url]]
479
+ : process.platform === 'win32'
480
+ ? ['cmd', ['/c', 'start', '', url]]
481
+ : process.platform === 'linux'
482
+ ? ['xdg-open', [url]]
483
+ : null;
484
+
485
+ if (!command) {
486
+ return;
487
+ }
488
+
489
+ try {
490
+ const child = spawn(command[0], command[1], {
491
+ detached: true,
492
+ stdio: 'ignore',
493
+ });
494
+ child.on('error', () => {});
495
+ child.unref();
496
+ } catch (_error) {
497
+ // Ignore browser launch failures. The dashboard URL is already printed.
498
+ }
499
+ }
500
+
501
+ async function waitForDashboardExit(url) {
502
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
503
+ log(`[demo] Non-interactive terminal detected. Leaving the dashboard up for ${Math.round(DEMO_TIMEOUT_MS / 1000)} seconds: ${url}`);
504
+ await new Promise((resolve) => setTimeout(resolve, DEMO_TIMEOUT_MS));
505
+ return;
506
+ }
507
+
508
+ const rl = readline.createInterface({
509
+ input: process.stdin,
510
+ output: process.stdout,
511
+ });
512
+
513
+ try {
514
+ await rl.question('[demo] Press Enter to stop the demo and close the dashboard.\n');
515
+ } finally {
516
+ rl.close();
517
+ }
518
+ }
519
+
520
+ function log(message) {
521
+ process.stdout.write(`${message}\n`);
522
+ }
523
+
524
+ module.exports = {
525
+ runFullyFeaturedExample,
526
+ };
527
+
528
+ if (require.main === module) {
529
+ runFullyFeaturedExample().catch((error) => {
530
+ process.stderr.write(`[demo] ${error.message}\n`);
531
+ process.exitCode = 1;
532
+ });
533
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtharrison/loupe",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Lightweight local tracing dashboard for LLM calls",
5
5
  "author": "Matt Harrison",
6
6
  "license": "MIT",
@@ -24,13 +24,14 @@
24
24
  ],
25
25
  "sideEffects": false,
26
26
  "engines": {
27
- "node": ">=18"
27
+ "node": ">=20"
28
28
  },
29
29
  "publishConfig": {
30
30
  "access": "public"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json && node scripts/build-ui.mjs",
34
+ "lint": "tsc --noEmit -p tsconfig.json && node --check examples/fully-featured.js && node --check examples/nested-tool-call.js && node --check examples/openai-multiturn-tools.js && node --check test/index.test.js",
34
35
  "release": "npx semantic-release",
35
36
  "test": "npm run build && node --test"
36
37
  },