wave-agent-sdk 0.18.7 → 0.19.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 (100) hide show
  1. package/builtin/skills/settings/ENV.md +0 -1
  2. package/builtin/skills/settings/HOOKS.md +10 -0
  3. package/builtin/skills/settings/MODELS.md +55 -10
  4. package/builtin/skills/settings/SKILL.md +8 -4
  5. package/builtin/skills/settings/SKILLS.md +6 -4
  6. package/dist/agent.d.ts.map +1 -1
  7. package/dist/agent.js +12 -5
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/managers/aiManager.d.ts.map +1 -1
  12. package/dist/managers/aiManager.js +20 -4
  13. package/dist/managers/mcpManager.d.ts.map +1 -1
  14. package/dist/managers/mcpManager.js +7 -2
  15. package/dist/managers/messageManager.d.ts +3 -5
  16. package/dist/managers/messageManager.d.ts.map +1 -1
  17. package/dist/managers/messageManager.js +17 -77
  18. package/dist/managers/skillManager.d.ts +1 -0
  19. package/dist/managers/skillManager.d.ts.map +1 -1
  20. package/dist/managers/skillManager.js +11 -2
  21. package/dist/prompts/index.d.ts +3 -2
  22. package/dist/prompts/index.d.ts.map +1 -1
  23. package/dist/prompts/index.js +5 -1
  24. package/dist/services/aiService.d.ts.map +1 -1
  25. package/dist/services/aiService.js +16 -47
  26. package/dist/services/configurationService.d.ts.map +1 -1
  27. package/dist/services/configurationService.js +25 -19
  28. package/dist/services/hook.d.ts.map +1 -1
  29. package/dist/services/hook.js +6 -0
  30. package/dist/services/initializationService.d.ts.map +1 -1
  31. package/dist/services/initializationService.js +1 -1
  32. package/dist/services/interactionService.js +1 -1
  33. package/dist/services/reversionService.d.ts +5 -1
  34. package/dist/services/reversionService.d.ts.map +1 -1
  35. package/dist/services/reversionService.js +50 -22
  36. package/dist/services/session.d.ts +4 -13
  37. package/dist/services/session.d.ts.map +1 -1
  38. package/dist/services/session.js +59 -215
  39. package/dist/telemetry/sessionTracing.d.ts +14 -4
  40. package/dist/telemetry/sessionTracing.d.ts.map +1 -1
  41. package/dist/telemetry/sessionTracing.js +134 -8
  42. package/dist/tools/bashTool.d.ts.map +1 -1
  43. package/dist/tools/bashTool.js +16 -1
  44. package/dist/types/config.d.ts +11 -2
  45. package/dist/types/config.d.ts.map +1 -1
  46. package/dist/types/hooks.d.ts +1 -0
  47. package/dist/types/hooks.d.ts.map +1 -1
  48. package/dist/types/messaging.d.ts +0 -1
  49. package/dist/types/messaging.d.ts.map +1 -1
  50. package/dist/types/skills.d.ts +1 -0
  51. package/dist/types/skills.d.ts.map +1 -1
  52. package/dist/types/telemetry.d.ts +14 -1
  53. package/dist/types/telemetry.d.ts.map +1 -1
  54. package/dist/utils/atomicWrite.d.ts +15 -0
  55. package/dist/utils/atomicWrite.d.ts.map +1 -0
  56. package/dist/utils/atomicWrite.js +59 -0
  57. package/dist/utils/cacheControlUtils.d.ts +3 -15
  58. package/dist/utils/cacheControlUtils.d.ts.map +1 -1
  59. package/dist/utils/cacheControlUtils.js +4 -35
  60. package/dist/utils/containerSetup.d.ts.map +1 -1
  61. package/dist/utils/containerSetup.js +5 -1
  62. package/dist/utils/convertMessagesForAPI.d.ts +9 -1
  63. package/dist/utils/convertMessagesForAPI.d.ts.map +1 -1
  64. package/dist/utils/convertMessagesForAPI.js +74 -55
  65. package/dist/utils/mcpUtils.d.ts.map +1 -1
  66. package/dist/utils/mcpUtils.js +2 -1
  67. package/dist/utils/modelCapabilities.d.ts +22 -0
  68. package/dist/utils/modelCapabilities.d.ts.map +1 -0
  69. package/dist/utils/modelCapabilities.js +24 -0
  70. package/dist/utils/skillParser.d.ts.map +1 -1
  71. package/dist/utils/skillParser.js +3 -1
  72. package/package.json +1 -1
  73. package/src/agent.ts +11 -5
  74. package/src/index.ts +1 -0
  75. package/src/managers/aiManager.ts +22 -2
  76. package/src/managers/mcpManager.ts +8 -2
  77. package/src/managers/messageManager.ts +22 -104
  78. package/src/managers/skillManager.ts +16 -2
  79. package/src/prompts/index.ts +6 -1
  80. package/src/services/aiService.ts +15 -85
  81. package/src/services/configurationService.ts +27 -41
  82. package/src/services/hook.ts +7 -0
  83. package/src/services/initializationService.ts +1 -3
  84. package/src/services/interactionService.ts +1 -1
  85. package/src/services/reversionService.ts +50 -27
  86. package/src/services/session.ts +63 -262
  87. package/src/telemetry/sessionTracing.ts +167 -12
  88. package/src/tools/bashTool.ts +23 -1
  89. package/src/types/config.ts +12 -2
  90. package/src/types/hooks.ts +1 -0
  91. package/src/types/messaging.ts +0 -1
  92. package/src/types/skills.ts +1 -0
  93. package/src/types/telemetry.ts +17 -1
  94. package/src/utils/atomicWrite.ts +61 -0
  95. package/src/utils/cacheControlUtils.ts +5 -38
  96. package/src/utils/containerSetup.ts +5 -1
  97. package/src/utils/convertMessagesForAPI.ts +87 -57
  98. package/src/utils/mcpUtils.ts +2 -1
  99. package/src/utils/modelCapabilities.ts +30 -0
  100. package/src/utils/skillParser.ts +3 -1
@@ -28,8 +28,6 @@ import { getMessageContent } from "../utils/messageOperations.js";
28
28
 
29
29
  export interface SessionData {
30
30
  id: string;
31
- rootSessionId?: string;
32
- parentSessionId?: string;
33
31
  messages: Message[];
34
32
  metadata: {
35
33
  workdir: string;
@@ -40,8 +38,6 @@ export interface SessionData {
40
38
 
41
39
  export interface SessionMetadata {
42
40
  id: string;
43
- rootSessionId?: string;
44
- parentSessionId?: string;
45
41
  sessionType: "main" | "subagent";
46
42
  subagentType?: string;
47
43
  workdir: string;
@@ -51,17 +47,6 @@ export interface SessionMetadata {
51
47
  firstMessage?: string;
52
48
  }
53
49
 
54
- export interface SessionIndex {
55
- sessions: Record<
56
- string,
57
- Omit<SessionMetadata, "id" | "lastActiveAt" | "createdAt"> & {
58
- lastActiveAt: string;
59
- createdAt: string;
60
- }
61
- >;
62
- lastUpdated: string;
63
- }
64
-
65
50
  /**
66
51
  * Generate a new session ID using Node.js native crypto.randomUUID()
67
52
  * @returns UUID string for session identification
@@ -82,40 +67,6 @@ export function generateSubagentFilename(sessionId: string): string {
82
67
  // Constants
83
68
  export const SESSION_DIR = join(homedir(), ".wave", "projects");
84
69
  const MAX_SESSION_AGE_DAYS = 14;
85
- const SESSION_INDEX_FILENAME = "sessions-index.json";
86
-
87
- /**
88
- * Update the session index for a project directory
89
- */
90
- async function updateSessionIndex(
91
- projectDirPath: string,
92
- metadata: SessionMetadata,
93
- ): Promise<void> {
94
- const indexPath = join(projectDirPath, SESSION_INDEX_FILENAME);
95
- let index: SessionIndex = {
96
- sessions: {},
97
- lastUpdated: new Date().toISOString(),
98
- };
99
-
100
- try {
101
- const content = await fs.readFile(indexPath, "utf8");
102
- index = JSON.parse(content);
103
- } catch {
104
- // Index doesn't exist or is invalid, start fresh
105
- }
106
-
107
- const { id, ...rest } = metadata;
108
- index.sessions[id] = {
109
- ...rest,
110
- createdAt: metadata.createdAt.toISOString(),
111
- lastActiveAt: metadata.lastActiveAt.toISOString(),
112
- firstMessage: metadata.firstMessage || index.sessions[id]?.firstMessage,
113
- parentSessionId: metadata.parentSessionId,
114
- };
115
- index.lastUpdated = new Date().toISOString();
116
-
117
- await fs.writeFile(indexPath, JSON.stringify(index, null, 2), "utf8");
118
- }
119
70
 
120
71
  /**
121
72
  * Ensure session directory exists
@@ -201,8 +152,6 @@ export async function appendMessages(
201
152
  newMessages: Message[],
202
153
  workdir: string,
203
154
  sessionType: "main" | "subagent" = "main",
204
- rootSessionId?: string,
205
- parentSessionId?: string,
206
155
  ): Promise<void> {
207
156
  // Do not save session files in test environment
208
157
  if (process.env.NODE_ENV === "test") {
@@ -235,50 +184,6 @@ export async function appendMessages(
235
184
  await jsonlHandler.append(filePath, newMessages, {
236
185
  atomic: false,
237
186
  });
238
-
239
- // Update index
240
- const encoder = new PathEncoder();
241
- const projectDir = await encoder.getProjectDirectory(workdir, SESSION_DIR);
242
- const lastMessage = newMessages[newMessages.length - 1];
243
-
244
- // Get first message content and createdAt from existing index
245
- let firstMessage: string | undefined;
246
- let createdAt: Date | undefined;
247
- try {
248
- const indexPath = join(projectDir.encodedPath, SESSION_INDEX_FILENAME);
249
- const content = await fs.readFile(indexPath, "utf8");
250
- const index = JSON.parse(content) as SessionIndex;
251
- if (!index.sessions[sessionId]?.firstMessage) {
252
- firstMessage =
253
- (await getFirstMessageContent(sessionId, workdir)) || undefined;
254
- }
255
- if (index.sessions[sessionId]?.createdAt) {
256
- createdAt = new Date(index.sessions[sessionId].createdAt);
257
- }
258
- } catch {
259
- // If index doesn't exist, this might be the first message
260
- firstMessage =
261
- (await getFirstMessageContent(sessionId, workdir)) || undefined;
262
- }
263
-
264
- // Derive createdAt from session ID if not in index
265
- if (!createdAt) {
266
- createdAt = new Date();
267
- }
268
-
269
- await updateSessionIndex(projectDir.encodedPath, {
270
- id: sessionId,
271
- rootSessionId,
272
- parentSessionId,
273
- sessionType,
274
- workdir,
275
- createdAt,
276
- lastActiveAt: new Date(lastMessage.timestamp),
277
- latestTotalTokens: lastMessage.usage
278
- ? extractLatestTotalTokens([lastMessage])
279
- : 0,
280
- firstMessage,
281
- });
282
187
  }
283
188
 
284
189
  /**
@@ -314,34 +219,25 @@ export async function loadSessionFromJsonl(
314
219
  throw error;
315
220
  }
316
221
 
317
- const messages = await jsonlHandler.read(filePath);
222
+ const allMessages = await jsonlHandler.read(filePath);
223
+
224
+ // Find the last compact boundary — only return messages from there forward
225
+ let lastCompactIndex = -1;
226
+ for (let i = allMessages.length - 1; i >= 0; i--) {
227
+ if (allMessages[i].blocks?.some((b) => b.type === "compact")) {
228
+ lastCompactIndex = i;
229
+ break;
230
+ }
231
+ }
232
+ const messages =
233
+ lastCompactIndex >= 0 ? allMessages.slice(lastCompactIndex) : allMessages;
318
234
 
319
235
  // Extract metadata from messages
320
236
  const lastMessage =
321
237
  messages.length > 0 ? messages[messages.length - 1] : null;
322
238
 
323
- // Try to get rootSessionId and parentSessionId from index
324
- let rootSessionId: string | undefined;
325
- let parentSessionId: string | undefined;
326
- try {
327
- const encoder = new PathEncoder();
328
- const projectDir = await encoder.getProjectDirectory(
329
- workdir,
330
- SESSION_DIR,
331
- );
332
- const indexPath = join(projectDir.encodedPath, SESSION_INDEX_FILENAME);
333
- const indexContent = await fs.readFile(indexPath, "utf8");
334
- const index = JSON.parse(indexContent) as SessionIndex;
335
- rootSessionId = index.sessions[sessionId]?.rootSessionId;
336
- parentSessionId = index.sessions[sessionId]?.parentSessionId;
337
- } catch {
338
- // Ignore index errors
339
- }
340
-
341
239
  const sessionData: SessionData = {
342
240
  id: sessionId,
343
- rootSessionId: rootSessionId || sessionId,
344
- parentSessionId,
345
241
  messages,
346
242
  metadata: {
347
243
  workdir,
@@ -435,33 +331,9 @@ export async function listSessionsFromJsonl(
435
331
 
436
332
  const projectDir = await encoder.getProjectDirectory(workdir, baseDir);
437
333
 
438
- // Try to read from index first
439
- const indexPath = join(projectDir.encodedPath, SESSION_INDEX_FILENAME);
440
334
  const sevenDaysAgo = new Date();
441
335
  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
442
336
 
443
- try {
444
- const indexContent = await fs.readFile(indexPath, "utf8");
445
- const index = JSON.parse(indexContent) as SessionIndex;
446
- const sessions: SessionMetadata[] = Object.entries(index.sessions)
447
- .filter(([, meta]) => {
448
- const lastActiveAt = new Date(meta.lastActiveAt);
449
- return meta.sessionType === "main" && lastActiveAt >= sevenDaysAgo;
450
- })
451
- .map(([id, meta]) => ({
452
- id,
453
- ...meta,
454
- createdAt: new Date(meta.createdAt),
455
- lastActiveAt: new Date(meta.lastActiveAt),
456
- }));
457
-
458
- return sessions.sort(
459
- (a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime(),
460
- );
461
- } catch {
462
- // Fallback to manual listing if index fails
463
- }
464
-
465
337
  let files: string[];
466
338
  try {
467
339
  files = await fs.readdir(projectDir.encodedPath);
@@ -521,7 +393,7 @@ export async function listSessionsFromJsonl(
521
393
  const sessionMeta: SessionMetadata = {
522
394
  id: sessionId,
523
395
  sessionType: "main",
524
- subagentType: undefined, // No longer stored in metadata
396
+ subagentType: undefined,
525
397
  workdir: projectDir.originalPath,
526
398
  createdAt: new Date(),
527
399
  lastActiveAt,
@@ -530,7 +402,7 @@ export async function listSessionsFromJsonl(
530
402
  : 0,
531
403
  };
532
404
 
533
- // Try to get first message content for the fallback/rebuild case
405
+ // Try to get first message content for display
534
406
  try {
535
407
  const firstContent = await getFirstMessageContent(sessionId, workdir);
536
408
  if (firstContent) {
@@ -548,30 +420,9 @@ export async function listSessionsFromJsonl(
548
420
  }
549
421
 
550
422
  // Sort by last active time (most recently active first)
551
- const sortedSessions = sessions.sort(
423
+ return sessions.sort(
552
424
  (a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime(),
553
425
  );
554
-
555
- // Rebuild index if we had to fall back
556
- try {
557
- const index: SessionIndex = {
558
- sessions: {},
559
- lastUpdated: new Date().toISOString(),
560
- };
561
- for (const session of sessions) {
562
- const { id, ...rest } = session;
563
- index.sessions[id] = {
564
- ...rest,
565
- createdAt: session.createdAt.toISOString(),
566
- lastActiveAt: session.lastActiveAt.toISOString(),
567
- };
568
- }
569
- await fs.writeFile(indexPath, JSON.stringify(index, null, 2), "utf8");
570
- } catch (error) {
571
- logger.warn(`Failed to rebuild session index for ${workdir}:`, error);
572
- }
573
-
574
- return sortedSessions;
575
426
  } catch (error) {
576
427
  throw new Error(`Failed to list sessions: ${error}`);
577
428
  }
@@ -607,28 +458,52 @@ export async function listAllSessions(): Promise<SessionMetadata[]> {
607
458
  continue;
608
459
  }
609
460
 
610
- // Try to read from index first
611
- const indexPath = join(projectPath, SESSION_INDEX_FILENAME);
612
- try {
613
- const indexContent = await fs.readFile(indexPath, "utf8");
614
- const index = JSON.parse(indexContent) as SessionIndex;
615
- for (const [id, meta] of Object.entries(index.sessions)) {
616
- const lastActiveAt = new Date(meta.lastActiveAt);
617
- if (meta.sessionType === "main" && lastActiveAt >= sevenDaysAgo) {
618
- allSessions.push({
619
- id,
620
- ...meta,
621
- createdAt: new Date(meta.createdAt),
622
- lastActiveAt,
623
- });
461
+ // Scan .jsonl files in the project directory
462
+ const files = await fs.readdir(projectPath);
463
+
464
+ for (const file of files) {
465
+ if (!file.endsWith(".jsonl") || file.startsWith("subagent-")) {
466
+ continue;
467
+ }
468
+
469
+ try {
470
+ const filePath = join(projectPath, file);
471
+ const uuidMatch = file.match(
472
+ /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
473
+ );
474
+ if (!uuidMatch) continue;
475
+
476
+ const sessionId = uuidMatch[1];
477
+ const jsonlHandler = new JsonlHandler();
478
+ const lastMessage = await jsonlHandler.getLastMessage(filePath);
479
+
480
+ let lastActiveAt: Date;
481
+ if (lastMessage) {
482
+ lastActiveAt = new Date(lastMessage.timestamp);
483
+ } else {
484
+ const stats = await fs.stat(filePath);
485
+ lastActiveAt = stats.mtime;
624
486
  }
487
+
488
+ if (lastActiveAt < sevenDaysAgo) continue;
489
+
490
+ allSessions.push({
491
+ id: sessionId,
492
+ sessionType: "main" as const,
493
+ subagentType: undefined,
494
+ workdir: projectDirName,
495
+ createdAt: new Date(),
496
+ lastActiveAt,
497
+ latestTotalTokens: lastMessage?.usage
498
+ ? extractLatestTotalTokens([lastMessage])
499
+ : 0,
500
+ });
501
+ } catch {
502
+ continue;
625
503
  }
626
- } catch {
627
- // If index fails, we skip this project directory
628
- // In the future, we could scan for .jsonl files here
629
504
  }
630
505
  } catch {
631
- // Skip if stat fails
506
+ // Skip if stat/readdir fails
632
507
  }
633
508
  }
634
509
 
@@ -677,38 +552,6 @@ export async function cleanupExpiredSessionsFromJsonl(
677
552
  if (fileAge > maxAge) {
678
553
  await fs.unlink(filePath);
679
554
  deletedCount++;
680
-
681
- // Remove from index if it exists
682
- try {
683
- const indexPath = join(
684
- projectDir.encodedPath,
685
- SESSION_INDEX_FILENAME,
686
- );
687
- const indexContent = await fs.readFile(indexPath, "utf8");
688
- const index = JSON.parse(indexContent) as SessionIndex;
689
- const uuidMatch = file.match(
690
- /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
691
- );
692
- const subagentMatch = file.match(
693
- /^subagent-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/,
694
- );
695
- const sessionId = uuidMatch
696
- ? uuidMatch[1]
697
- : subagentMatch
698
- ? subagentMatch[1]
699
- : null;
700
-
701
- if (sessionId && index.sessions[sessionId]) {
702
- delete index.sessions[sessionId];
703
- await fs.writeFile(
704
- indexPath,
705
- JSON.stringify(index, null, 2),
706
- "utf8",
707
- );
708
- }
709
- } catch {
710
- // Ignore index update errors during cleanup
711
- }
712
555
  }
713
556
  } catch {
714
557
  // Skip failed operations and continue processing other files
@@ -897,21 +740,6 @@ export async function deleteSession(
897
740
  throw error;
898
741
  }
899
742
  }
900
-
901
- // Also remove from index
902
- try {
903
- const encoder = new PathEncoder();
904
- const projectDir = await encoder.getProjectDirectory(workdir, SESSION_DIR);
905
- const indexPath = join(projectDir.encodedPath, SESSION_INDEX_FILENAME);
906
- const indexContent = await fs.readFile(indexPath, "utf8");
907
- const index = JSON.parse(indexContent) as SessionIndex;
908
- if (index.sessions[sessionId]) {
909
- delete index.sessions[sessionId];
910
- await fs.writeFile(indexPath, JSON.stringify(index, null, 2), "utf8");
911
- }
912
- } catch {
913
- // Ignore index errors
914
- }
915
743
  }
916
744
 
917
745
  /**
@@ -995,7 +823,9 @@ export async function handleSessionRestoration(
995
823
  }
996
824
 
997
825
  /**
998
- * Load the full message thread by following parentSessionId links
826
+ * Load the full message thread for a session.
827
+ * With append-only compaction, all messages are in a single file.
828
+ * Returns the active messages (post-compact boundary).
999
829
  * @param currentSessionId - The ID of the current session
1000
830
  * @param workdir - Working directory for the session
1001
831
  * @returns Promise that resolves to an array of all messages in the thread
@@ -1004,36 +834,7 @@ export async function loadFullMessageThread(
1004
834
  currentSessionId: string,
1005
835
  workdir: string,
1006
836
  ): Promise<{ messages: Message[]; sessionIds: string[] }> {
1007
- const sessionIds: string[] = [];
1008
- let currentId: string | undefined = currentSessionId;
1009
- const allMessages: Message[] = [];
1010
-
1011
- while (currentId) {
1012
- const sessionData = await loadSessionFromJsonl(currentId, workdir);
1013
- if (!sessionData) break;
1014
-
1015
- sessionIds.unshift(currentId);
1016
- // Add messages from this session to the beginning of the list
1017
- // But skip the "compact" block if it's not the first session in our traversal (which is the latest)
1018
- // Actually, we should probably keep all messages and let the UI/logic handle it.
1019
- // But wait, if we are concatenating, the "compact" block in session N summarizes session N-1.
1020
- // So if we have session N-1 and session N, we should probably skip the compact block in session N.
1021
-
1022
- const messages = sessionData.messages;
1023
- if (allMessages.length > 0) {
1024
- // If we already have messages (from "later" sessions),
1025
- // we are now adding messages from an "earlier" session.
1026
- // The later session's first message might be a "compact" block.
1027
- if (allMessages[0].blocks.some((b) => b.type === "compact")) {
1028
- // Remove the compact block from the later session's messages
1029
- // because we are now providing the actual messages it summarized.
1030
- allMessages.shift();
1031
- }
1032
- }
1033
-
1034
- allMessages.unshift(...messages);
1035
- currentId = sessionData.parentSessionId;
1036
- }
1037
-
1038
- return { messages: allMessages, sessionIds };
837
+ const sessionData = await loadSessionFromJsonl(currentSessionId, workdir);
838
+ if (!sessionData) return { messages: [], sessionIds: [] };
839
+ return { messages: sessionData.messages, sessionIds: [currentSessionId] };
1039
840
  }
@@ -11,19 +11,55 @@
11
11
  */
12
12
 
13
13
  import { AsyncLocalStorage } from "node:async_hooks";
14
+ import { createHash } from "node:crypto";
14
15
  import type { Span } from "@opentelemetry/api";
15
16
  import {
16
17
  getOTELApi,
17
18
  isInitialized,
18
19
  getCurrentConfig,
19
20
  } from "./instrumentation.js";
20
- import type { LLMRequestMetadata, ToolMetadata } from "../types/telemetry.js";
21
+ import { logOTelEvent } from "./events.js";
22
+ import type {
23
+ LLMRequestMetadata,
24
+ LLMRequestInput,
25
+ ToolMetadata,
26
+ } from "../types/telemetry.js";
27
+
28
+ // -- Constants --
29
+
30
+ /** Max content length for span attributes (60KB, aligning with Claude Code) */
31
+ const MAX_CONTENT_LENGTH = 60000;
21
32
 
22
33
  // -- AsyncLocalStorage for context propagation --
23
34
 
24
35
  const interactionContext = new AsyncLocalStorage<Span | undefined>();
25
36
  const toolContext = new AsyncLocalStorage<Span | undefined>();
26
37
 
38
+ // -- Incremental tracking state (module-level, per session) --
39
+
40
+ /** Number of messages already reported in new_context; only deltas are sent */
41
+ let lastReportedMessageCount = 0;
42
+ /** Set of system prompt hashes already emitted via logOTelEvent */
43
+ const seenSystemPromptHashes = new Set<string>();
44
+ /** Set of tool schema hashes already emitted via logOTelEvent */
45
+ const seenToolSchemaHashes = new Set<string>();
46
+
47
+ // -- Helpers --
48
+
49
+ /** Computes SHA-256 hash of content, returns first 12 hex characters */
50
+ function hashContent(content: string): string {
51
+ return createHash("sha256").update(content).digest("hex").substring(0, 12);
52
+ }
53
+
54
+ /** Truncates content to MAX_CONTENT_LENGTH, returns [truncated, originalLength] */
55
+ function truncateContent(content: string): [string, number] {
56
+ const originalLength = content.length;
57
+ if (originalLength > MAX_CONTENT_LENGTH) {
58
+ return [content.substring(0, MAX_CONTENT_LENGTH), originalLength];
59
+ }
60
+ return [content, originalLength];
61
+ }
62
+
27
63
  // -- Tracer accessor --
28
64
 
29
65
  function getTracer() {
@@ -71,18 +107,33 @@ export function endInteractionSpan(): void {
71
107
  interactionContext.enterWith(undefined);
72
108
  }
73
109
 
110
+ /**
111
+ * Resets incremental tracing state. Should be called after compaction to
112
+ * prevent incorrect delta calculations.
113
+ */
114
+ export function resetTracingState(): void {
115
+ lastReportedMessageCount = 0;
116
+ seenSystemPromptHashes.clear();
117
+ seenToolSchemaHashes.clear();
118
+ }
119
+
74
120
  /**
75
121
  * Creates an LLM request span as a child of the interaction span.
76
122
  * Does NOT enter any ALS — the span must be passed explicitly to endLLMRequestSpan.
123
+ *
124
+ * When logToolContent is enabled, captures:
125
+ * - system_prompt_hash / system_prompt_preview / system_prompt_length
126
+ * - new_context (incremental messages delta)
127
+ * - tools (JSON array of {name, hash}) / tools_count
77
128
  */
78
129
  export function startLLMRequestSpan(
79
130
  model: string,
80
- options?: { context?: string },
131
+ options?: LLMRequestInput,
81
132
  ): Span | undefined {
82
133
  const tracer = getTracer();
83
134
  if (!tracer) return undefined;
84
135
 
85
- const attributes: Record<string, string> = {
136
+ const attributes: Record<string, string | number | boolean> = {
86
137
  "span.type": "llm_request",
87
138
  model,
88
139
  };
@@ -90,6 +141,91 @@ export function startLLMRequestSpan(
90
141
  attributes["llm_request.context"] = options.context;
91
142
  }
92
143
 
144
+ const config = getCurrentConfig();
145
+
146
+ // Content capture gated by logToolContent
147
+ if (config?.logToolContent) {
148
+ // System prompt: hash + preview + length
149
+ if (options?.systemPrompt) {
150
+ const hash = hashContent(options.systemPrompt);
151
+ attributes.system_prompt_hash = hash;
152
+ attributes.system_prompt_length = options.systemPrompt.length;
153
+ const preview =
154
+ options.systemPrompt.length > 500
155
+ ? options.systemPrompt.substring(0, 500)
156
+ : options.systemPrompt;
157
+ attributes.system_prompt_preview = preview;
158
+
159
+ // Emit full system prompt via event only once per hash
160
+ if (!seenSystemPromptHashes.has(hash)) {
161
+ seenSystemPromptHashes.add(hash);
162
+ logOTelEvent("system_prompt", {
163
+ system_prompt_hash: hash,
164
+ system_prompt: options.systemPrompt,
165
+ }).catch(() => {});
166
+ }
167
+ }
168
+
169
+ // Incremental input messages: only send new messages since last report
170
+ if (options?.inputMessages && options.inputMessages.length > 0) {
171
+ const totalCount = options.inputMessages.length;
172
+ const deltaCount = totalCount - lastReportedMessageCount;
173
+ if (deltaCount > 0) {
174
+ const newMessages = options.inputMessages.slice(
175
+ lastReportedMessageCount,
176
+ );
177
+ const newContextStr = JSON.stringify(newMessages);
178
+ const [truncated, originalLength] = truncateContent(newContextStr);
179
+ attributes.new_context = truncated;
180
+ attributes.new_context_message_count = deltaCount;
181
+ if (originalLength > MAX_CONTENT_LENGTH) {
182
+ attributes.new_context_truncated = true;
183
+ attributes.new_context_original_length = originalLength;
184
+ }
185
+ }
186
+ lastReportedMessageCount = totalCount;
187
+ }
188
+
189
+ // Tool schemas: hash per tool + count
190
+ if (options?.toolsSchema) {
191
+ try {
192
+ const tools = JSON.parse(options.toolsSchema);
193
+ if (Array.isArray(tools)) {
194
+ const toolHashes = tools.map(
195
+ (tool: { function?: { name?: string } }) => {
196
+ const name = tool.function?.name || "unknown";
197
+ const toolStr = JSON.stringify(tool);
198
+ const hash = hashContent(toolStr);
199
+ return { name, hash };
200
+ },
201
+ );
202
+ attributes.tools = JSON.stringify(toolHashes);
203
+ attributes.tools_count = toolHashes.length;
204
+
205
+ // Emit full tool schema via event only once per hash
206
+ for (const { name, hash } of toolHashes) {
207
+ if (!seenToolSchemaHashes.has(hash)) {
208
+ seenToolSchemaHashes.add(hash);
209
+ const fullTool = tools.find(
210
+ (t: { function?: { name?: string } }) =>
211
+ t.function?.name === name,
212
+ );
213
+ if (fullTool) {
214
+ logOTelEvent("tool_schema", {
215
+ tool_name: name,
216
+ tool_hash: hash,
217
+ tool_schema: JSON.stringify(fullTool),
218
+ }).catch(() => {});
219
+ }
220
+ }
221
+ }
222
+ }
223
+ } catch {
224
+ // toolsSchema is not valid JSON — skip
225
+ }
226
+ }
227
+ }
228
+
93
229
  const parent = interactionContext.getStore();
94
230
  let span: Span;
95
231
  if (parent) {
@@ -105,6 +241,8 @@ export function startLLMRequestSpan(
105
241
  /**
106
242
  * Ends an LLM request span with response metadata.
107
243
  * The span is passed explicitly — no ALS is read or modified.
244
+ *
245
+ * When logToolContent is enabled, captures response.model_output.
108
246
  */
109
247
  export function endLLMRequestSpan(
110
248
  span: Span | undefined,
@@ -127,6 +265,20 @@ export function endLLMRequestSpan(
127
265
  if (metadata.hasToolCall != null)
128
266
  span.setAttribute("has_tool_call", metadata.hasToolCall);
129
267
 
268
+ // Content capture gated by logToolContent
269
+ const config = getCurrentConfig();
270
+ if (config?.logToolContent && metadata.modelOutput) {
271
+ const [truncated, originalLength] = truncateContent(metadata.modelOutput);
272
+ span.setAttribute("response.model_output", truncated);
273
+ if (originalLength > MAX_CONTENT_LENGTH) {
274
+ span.setAttribute("response.model_output_truncated", true);
275
+ span.setAttribute(
276
+ "response.model_output_original_length",
277
+ originalLength,
278
+ );
279
+ }
280
+ }
281
+
130
282
  span.end();
131
283
  }
132
284
 
@@ -142,16 +294,18 @@ export function startToolSpan(
142
294
  if (!tracer) return undefined;
143
295
 
144
296
  const config = getCurrentConfig();
145
- const attributes: Record<string, string | number> = {
297
+ const attributes: Record<string, string | number | boolean> = {
146
298
  "span.type": "tool",
147
299
  tool_name: toolName,
148
300
  };
149
301
  if (config?.logToolContent && input !== undefined) {
150
- let inputStr = typeof input === "string" ? input : JSON.stringify(input);
151
- if (inputStr.length > 1000) {
152
- inputStr = inputStr.substring(0, 1000);
302
+ const inputStr = typeof input === "string" ? input : JSON.stringify(input);
303
+ const [truncated, originalLength] = truncateContent(inputStr);
304
+ attributes.tool_input = truncated;
305
+ if (originalLength > MAX_CONTENT_LENGTH) {
306
+ attributes.tool_input_truncated = true;
307
+ attributes.tool_input_original_length = originalLength;
153
308
  }
154
- attributes.tool_input = inputStr;
155
309
  }
156
310
 
157
311
  const parent = interactionContext.getStore();
@@ -181,11 +335,12 @@ export function endToolSpan(metadata: ToolMetadata): void {
181
335
 
182
336
  const config = getCurrentConfig();
183
337
  if (config?.logToolContent && metadata.output) {
184
- let outputStr = metadata.output;
185
- if (outputStr.length > 1000) {
186
- outputStr = outputStr.substring(0, 1000);
338
+ const [truncated, originalLength] = truncateContent(metadata.output);
339
+ span.setAttribute("tool_output", truncated);
340
+ if (originalLength > MAX_CONTENT_LENGTH) {
341
+ span.setAttribute("tool_output_truncated", true);
342
+ span.setAttribute("tool_output_original_length", originalLength);
187
343
  }
188
- span.setAttribute("tool_output", outputStr);
189
344
  }
190
345
 
191
346
  span.end();