@tonyclaw/agent-inspector 2.0.4 → 2.0.5

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 (53) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BCH_fsLm.js → CompareDrawer-3nRwtk8J.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-CbW5VRER.js +101 -0
  4. package/.output/public/assets/ReplayDialog-Cl62N9PI.js +1 -0
  5. package/.output/public/assets/{RequestAnatomy-DZ8grAih.js → RequestAnatomy-DgQWGvjs.js} +1 -1
  6. package/.output/public/assets/ResponseView-Cvc-ct4E.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-BCQaCAIe.js +1 -0
  8. package/.output/public/assets/_sessionId-CcD_aLGq.js +1 -0
  9. package/.output/public/assets/index-B_dffD3u.js +1 -0
  10. package/.output/public/assets/index-CX796gvi.css +1 -0
  11. package/.output/public/assets/{json-viewer-BrzjD7qI.js → json-viewer-IXejqXB0.js} +1 -1
  12. package/.output/public/assets/{main-mgxeUdZQ.js → main-2NlGzgOe.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +181 -114
  14. package/.output/server/{_sessionId-C4xsxIWm.mjs → _sessionId-DWCTasJU.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-DuWEpqQ7.mjs → CompareDrawer-DhrN1uC2.mjs} +6 -6
  16. package/.output/server/_ssr/{ProxyViewerContainer-Cckz5qKu.mjs → ProxyViewerContainer-DRl51s_n.mjs} +763 -119
  17. package/.output/server/_ssr/{ReplayDialog-BDRcr8E5.mjs → ReplayDialog-BQT_ygxC.mjs} +240 -14
  18. package/.output/server/_ssr/{RequestAnatomy-BoO2_Ij0.mjs → RequestAnatomy-DS2tZOgq.mjs} +3 -3
  19. package/.output/server/_ssr/{ResponseView-DZiPBxvO.mjs → ResponseView-e0kL2C3x.mjs} +8 -8
  20. package/.output/server/_ssr/{StreamingChunkSequence-D-be7KEL.mjs → StreamingChunkSequence-BJG-m7xs.mjs} +3 -3
  21. package/.output/server/_ssr/{index-5RImHKfu.mjs → index-Dea3OeRw.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-aJhb93ZK.mjs → json-viewer-DDU55MLK.mjs} +3 -3
  24. package/.output/server/_ssr/{router-Dgkv5nKP.mjs → router-Dl7oh0zx.mjs} +145 -71
  25. package/.output/server/_tanstack-start-manifest_v-m-FJNBVf.mjs +4 -0
  26. package/.output/server/index.mjs +69 -69
  27. package/package.json +1 -1
  28. package/src/components/OnboardingBanner.tsx +11 -19
  29. package/src/components/ProxyViewer.tsx +1 -1
  30. package/src/components/providers/ProviderCard.tsx +6 -20
  31. package/src/components/providers/SettingsDialog.tsx +95 -2
  32. package/src/components/proxy-viewer/AgentTraceSummary.tsx +639 -38
  33. package/src/components/proxy-viewer/CompareDrawer.tsx +4 -2
  34. package/src/components/proxy-viewer/LogEntryHeader.tsx +12 -22
  35. package/src/components/proxy-viewer/ReplayDialog.tsx +190 -8
  36. package/src/components/proxy-viewer/ResponseView.tsx +2 -2
  37. package/src/components/proxy-viewer/ToolTraceEvents.tsx +37 -16
  38. package/src/components/proxy-viewer/TurnGroup.tsx +14 -2
  39. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +2 -2
  40. package/src/components/proxy-viewer/replayComparison.ts +131 -0
  41. package/src/components/proxy-viewer/useKeyboardNavigation.ts +64 -22
  42. package/src/components/proxy-viewer/viewerState.ts +14 -2
  43. package/src/knowledge/candidateStore.ts +32 -1
  44. package/src/routes/api/knowledge.candidates.$candidateId.ts +50 -0
  45. package/src/routes/api/knowledge.sessions.$sessionId.candidates.ts +12 -2
  46. package/.output/public/assets/ProxyViewerContainer-D85_UANk.js +0 -101
  47. package/.output/public/assets/ReplayDialog-DTeaHHit.js +0 -1
  48. package/.output/public/assets/ResponseView-Cldm6RCi.js +0 -1
  49. package/.output/public/assets/StreamingChunkSequence-3x4p-yT7.js +0 -1
  50. package/.output/public/assets/_sessionId-YqWFBu6d.js +0 -1
  51. package/.output/public/assets/index-BIw2H6jO.js +0 -1
  52. package/.output/public/assets/index-CobXD0yH.css +0 -1
  53. package/.output/server/_tanstack-start-manifest_v-B8rrWXjr.mjs +0 -4
@@ -4,33 +4,81 @@ import {
4
4
  Brain,
5
5
  ChevronDown,
6
6
  ChevronRight,
7
+ CircleCheck,
7
8
  Clock,
9
+ FileSearch,
8
10
  Loader2,
9
11
  MessageSquare,
12
+ Pencil,
13
+ RefreshCw,
14
+ Save,
15
+ ShieldCheck,
16
+ UploadCloud,
10
17
  Wrench,
18
+ X,
19
+ XCircle,
11
20
  Zap,
12
21
  } from "lucide-react";
13
22
  import { z } from "zod";
14
23
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
15
24
  import { formatTimestampRange } from "../../lib/timeDisplay";
16
- import { formatTokens } from "../../lib/utils";
25
+ import { cn, formatTokens } from "../../lib/utils";
17
26
  import { parseJsonResponse, readApiError } from "../../lib/apiClient";
18
- import { KnowledgeCandidateSchema, type KnowledgeCandidate } from "../../knowledge/types";
27
+ import {
28
+ KnowledgeCandidateSchema,
29
+ type KnowledgeCandidate,
30
+ type KnowledgeCandidateStatus,
31
+ } from "../../knowledge/types";
19
32
  import type { CapturedLog } from "../../proxy/schemas";
20
33
  import { Badge } from "../ui/badge";
21
34
  import { Button } from "../ui/button";
22
- import { buildTraceSummary } from "./viewerState";
35
+ import { buildTraceSummary, extractToolTraceEvents, type TraceSummary } from "./viewerState";
23
36
 
24
37
  const CandidateResponseSchema = z.object({
25
38
  candidates: z.array(KnowledgeCandidateSchema),
26
39
  });
27
40
 
41
+ const CandidatePromotionResponseSchema = z.object({
42
+ candidate: KnowledgeCandidateSchema,
43
+ });
44
+
45
+ const CandidatePromotionFailureResponseSchema = z.object({
46
+ error: z.string().optional(),
47
+ candidate: KnowledgeCandidateSchema.optional(),
48
+ });
49
+
50
+ const CandidateUpdateResponseSchema = z.object({
51
+ candidate: KnowledgeCandidateSchema,
52
+ });
53
+
54
+ type CandidateDraftUpdate = {
55
+ type: KnowledgeCandidate["type"];
56
+ title: string;
57
+ content: string;
58
+ tags: string[];
59
+ };
60
+
28
61
  type CandidateLoadState =
29
62
  | { status: "idle"; error: null }
30
63
  | { status: "loading"; error: null }
31
64
  | { status: "ready"; error: null }
32
65
  | { status: "failed"; error: string };
33
66
 
67
+ type TraceInsightKind = "session" | "tool" | "slow" | "failure" | "candidate";
68
+
69
+ type TraceInsight = {
70
+ kind: TraceInsightKind;
71
+ title: string;
72
+ detail: string;
73
+ logId: number | null;
74
+ };
75
+
76
+ const CANDIDATE_STATUS_CLASSES: Record<KnowledgeCandidateStatus, string> = {
77
+ draft: "border-amber-500/30 bg-amber-500/10 text-amber-500",
78
+ promoted: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
79
+ failed: "border-destructive/30 bg-destructive/10 text-destructive",
80
+ };
81
+
34
82
  type AgentTraceSummaryProps = {
35
83
  logs: CapturedLog[];
36
84
  scopeId: string;
@@ -71,43 +119,466 @@ function jumpToLog(logId: number): void {
71
119
  target.focus({ preventScroll: true });
72
120
  }
73
121
 
74
- function CandidateList({ candidates }: { candidates: KnowledgeCandidate[] }): JSX.Element | null {
75
- if (candidates.length === 0) return null;
122
+ function firstFailureLog(logs: CapturedLog[]): CapturedLog | null {
123
+ for (const log of logs) {
124
+ if (log.responseStatus !== null && log.responseStatus >= 400) return log;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function slowestLog(logs: CapturedLog[]): CapturedLog | null {
130
+ let current: CapturedLog | null = null;
131
+ let currentElapsed = -1;
132
+ for (const log of logs) {
133
+ if (log.elapsedMs === null) continue;
134
+ if (log.elapsedMs > currentElapsed) {
135
+ current = log;
136
+ currentElapsed = log.elapsedMs;
137
+ }
138
+ }
139
+ return current;
140
+ }
141
+
142
+ function firstToolLog(logs: CapturedLog[]): CapturedLog | null {
143
+ for (const log of logs) {
144
+ if (extractToolTraceEvents(log).length > 0) return log;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function buildTraceInsights(input: {
150
+ logs: CapturedLog[];
151
+ scopeId: string;
152
+ summary: TraceSummary;
153
+ candidates: KnowledgeCandidate[];
154
+ }): TraceInsight[] {
155
+ const insights: TraceInsight[] = [
156
+ {
157
+ kind: "session",
158
+ title: `${String(input.summary.llmCallCount)} LLM call${
159
+ input.summary.llmCallCount === 1 ? "" : "s"
160
+ }`,
161
+ detail: `Scope ${input.scopeId}`,
162
+ logId: input.logs[0]?.id ?? null,
163
+ },
164
+ ];
165
+
166
+ const toolLog = firstToolLog(input.logs);
167
+ if (input.summary.toolCallCount > 0 && toolLog !== null) {
168
+ insights.push({
169
+ kind: "tool",
170
+ title: `${String(input.summary.toolCallCount)} tool call${
171
+ input.summary.toolCallCount === 1 ? "" : "s"
172
+ }`,
173
+ detail: `First tool evidence at #${String(toolLog.id)}`,
174
+ logId: toolLog.id,
175
+ });
176
+ }
177
+
178
+ const failure = firstFailureLog(input.logs);
179
+ if (failure !== null) {
180
+ insights.push({
181
+ kind: "failure",
182
+ title: `Failure #${String(failure.responseStatus)}`,
183
+ detail: `First failed request is #${String(failure.id)}`,
184
+ logId: failure.id,
185
+ });
186
+ }
187
+
188
+ const slowest = slowestLog(input.logs);
189
+ if (slowest !== null && input.summary.maxElapsedMs !== null) {
190
+ insights.push({
191
+ kind: "slow",
192
+ title: `Slowest ${formatElapsed(input.summary.maxElapsedMs)}`,
193
+ detail: `Max latency observed at #${String(slowest.id)}`,
194
+ logId: slowest.id,
195
+ });
196
+ }
197
+
198
+ if (input.candidates.length > 0) {
199
+ const draftCount = input.candidates.filter((candidate) => candidate.status === "draft").length;
200
+ const promotedCount = input.candidates.filter(
201
+ (candidate) => candidate.status === "promoted",
202
+ ).length;
203
+ insights.push({
204
+ kind: "candidate",
205
+ title: `${String(input.candidates.length)} memory candidate${
206
+ input.candidates.length === 1 ? "" : "s"
207
+ }`,
208
+ detail: `${String(draftCount)} draft / ${String(promotedCount)} promoted`,
209
+ logId: input.candidates[0]?.logIds[0] ?? null,
210
+ });
211
+ }
212
+
213
+ return insights;
214
+ }
215
+
216
+ function insightIcon(kind: TraceInsightKind): JSX.Element {
217
+ switch (kind) {
218
+ case "session":
219
+ return <MessageSquare className="size-3.5 text-blue-400" />;
220
+ case "tool":
221
+ return <Wrench className="size-3.5 text-sky-400/70" />;
222
+ case "slow":
223
+ return <Clock className="size-3.5 text-amber-400" />;
224
+ case "failure":
225
+ return <XCircle className="size-3.5 text-destructive" />;
226
+ case "candidate":
227
+ return <Brain className="size-3.5 text-emerald-400" />;
228
+ }
229
+ }
230
+
231
+ function TraceInsights({ insights }: { insights: TraceInsight[] }): JSX.Element | null {
232
+ if (insights.length === 0) return null;
76
233
  return (
77
- <div className="mt-2 grid gap-1.5">
78
- {candidates.map((candidate) => (
79
- <div
80
- key={candidate.id}
81
- className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2"
234
+ <div className="mt-2 grid gap-1.5 border-t border-border/70 pt-2 md:grid-cols-2 xl:grid-cols-3">
235
+ {insights.map((insight) => (
236
+ <button
237
+ key={`${insight.kind}-${insight.title}`}
238
+ type="button"
239
+ className={cn(
240
+ "flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs",
241
+ "text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
242
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
243
+ )}
244
+ onClick={() => {
245
+ if (insight.logId !== null) jumpToLog(insight.logId);
246
+ }}
247
+ disabled={insight.logId === null}
82
248
  >
83
- <div className="flex min-w-0 items-center gap-2">
84
- <Badge variant="outline" className="h-5 px-1.5 text-[10px] font-mono">
85
- {candidate.type}
86
- </Badge>
87
- <span className="min-w-0 flex-1 truncate text-xs font-medium" title={candidate.title}>
88
- {candidate.title}
89
- </span>
90
- <span className="shrink-0 font-mono text-[10px] text-muted-foreground">
91
- {candidate.status}
92
- </span>
249
+ {insightIcon(insight.kind)}
250
+ <span className="min-w-0">
251
+ <span className="block truncate font-medium text-foreground/90">{insight.title}</span>
252
+ <span className="block truncate font-mono text-[10px]">{insight.detail}</span>
253
+ </span>
254
+ </button>
255
+ ))}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ function candidateStatusLabel(status: KnowledgeCandidateStatus): string {
261
+ switch (status) {
262
+ case "draft":
263
+ return "Draft";
264
+ case "promoted":
265
+ return "Promoted";
266
+ case "failed":
267
+ return "Failed";
268
+ }
269
+ }
270
+
271
+ function candidatePromoteLabel(candidate: KnowledgeCandidate, promoting: boolean): string {
272
+ if (promoting) return "Promoting";
273
+ switch (candidate.status) {
274
+ case "draft":
275
+ return "Promote";
276
+ case "failed":
277
+ return "Retry";
278
+ case "promoted":
279
+ return "Promoted";
280
+ }
281
+ }
282
+
283
+ function redactionLabel(candidate: KnowledgeCandidate): string {
284
+ if (!candidate.redaction.redacted) return "No sensitive pattern matched";
285
+ return `Redacted ${candidate.redaction.patterns.join(", ")}`;
286
+ }
287
+
288
+ function previewText(value: string, maxLength: number): string {
289
+ const normalized = value.replace(/\s+/g, " ").trim();
290
+ if (normalized.length <= maxLength) return normalized;
291
+ return `${normalized.slice(0, maxLength - 3)}...`;
292
+ }
293
+
294
+ function logRangeLabel(logIds: number[]): string {
295
+ const first = logIds[0];
296
+ const last = logIds[logIds.length - 1];
297
+ if (first === undefined) return "No evidence logs";
298
+ if (last === undefined || first === last) return `#${String(first)}`;
299
+ return `#${String(first)}-#${String(last)}`;
300
+ }
301
+
302
+ function tagsToText(tags: string[]): string {
303
+ return tags.join(", ");
304
+ }
305
+
306
+ function textToTags(value: string): string[] {
307
+ return value
308
+ .split(",")
309
+ .map((tag) => tag.trim())
310
+ .filter((tag) => tag.length > 0);
311
+ }
312
+
313
+ function CandidateItem({
314
+ candidate,
315
+ isPromoting,
316
+ isUpdating,
317
+ onPromoteCandidate,
318
+ onUpdateCandidate,
319
+ }: {
320
+ candidate: KnowledgeCandidate;
321
+ isPromoting: boolean;
322
+ isUpdating: boolean;
323
+ onPromoteCandidate: (candidateId: string) => void;
324
+ onUpdateCandidate: (candidateId: string, update: CandidateDraftUpdate) => Promise<boolean>;
325
+ }): JSX.Element {
326
+ const [editing, setEditing] = useState(false);
327
+ const [draftType, setDraftType] = useState<KnowledgeCandidate["type"]>(candidate.type);
328
+ const [draftTitle, setDraftTitle] = useState(candidate.title);
329
+ const [draftContent, setDraftContent] = useState(candidate.content);
330
+ const [draftTags, setDraftTags] = useState(tagsToText(candidate.tags));
331
+ const [localError, setLocalError] = useState<string | null>(null);
332
+ const canPromote = candidate.status !== "promoted";
333
+ const canEdit = candidate.status !== "promoted";
334
+
335
+ const resetDraft = useCallback(() => {
336
+ setDraftType(candidate.type);
337
+ setDraftTitle(candidate.title);
338
+ setDraftContent(candidate.content);
339
+ setDraftTags(tagsToText(candidate.tags));
340
+ setLocalError(null);
341
+ }, [candidate.content, candidate.tags, candidate.title, candidate.type]);
342
+
343
+ const saveDraft = useCallback(async () => {
344
+ const title = draftTitle.trim();
345
+ const content = draftContent.trim();
346
+ const tags = textToTags(draftTags);
347
+ if (title.length === 0) {
348
+ setLocalError("Title is required.");
349
+ return;
350
+ }
351
+ if (content.length === 0) {
352
+ setLocalError("Content is required.");
353
+ return;
354
+ }
355
+ if (tags.length === 0) {
356
+ setLocalError("At least one tag is required.");
357
+ return;
358
+ }
359
+ setLocalError(null);
360
+ const saved = await onUpdateCandidate(candidate.id, {
361
+ type: draftType,
362
+ title,
363
+ content,
364
+ tags,
365
+ });
366
+ if (saved) setEditing(false);
367
+ }, [candidate.id, draftContent, draftTags, draftTitle, draftType, onUpdateCandidate]);
368
+
369
+ if (editing) {
370
+ return (
371
+ <div className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2">
372
+ <div className="grid gap-2">
373
+ <div className="flex flex-wrap items-center gap-2">
374
+ <select
375
+ value={draftType}
376
+ disabled={isUpdating}
377
+ onChange={(event) => {
378
+ const parsed = KnowledgeCandidateSchema.shape.type.safeParse(
379
+ event.currentTarget.value,
380
+ );
381
+ if (parsed.success) setDraftType(parsed.data);
382
+ }}
383
+ className="h-7 rounded-md border border-input bg-background px-2 text-xs"
384
+ aria-label="Candidate type"
385
+ >
386
+ <option value="episode">episode</option>
387
+ <option value="procedure">procedure</option>
388
+ <option value="preference">preference</option>
389
+ <option value="project-fact">project-fact</option>
390
+ </select>
391
+ <input
392
+ value={draftTitle}
393
+ disabled={isUpdating}
394
+ onChange={(event) => setDraftTitle(event.currentTarget.value)}
395
+ className="h-7 min-w-[220px] flex-1 rounded-md border border-input bg-background px-2 text-xs"
396
+ aria-label="Candidate title"
397
+ />
93
398
  </div>
94
- <div className="mt-1 flex flex-wrap items-center gap-1.5">
95
- {candidate.logIds.map((logId) => (
96
- <a
97
- key={logId}
98
- href={`#${getLogAnchor(logId)}`}
99
- onClick={(event) => {
100
- event.preventDefault();
101
- jumpToLog(logId);
102
- }}
103
- className="rounded border border-blue-400/25 px-1.5 py-0.5 font-mono text-[10px] text-blue-400 underline-offset-2 transition-colors hover:bg-blue-400/10 hover:text-blue-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
104
- aria-label={`Jump to evidence log ${String(logId)}`}
105
- >
106
- #{logId}
107
- </a>
108
- ))}
399
+ <textarea
400
+ value={draftContent}
401
+ disabled={isUpdating}
402
+ onChange={(event) => setDraftContent(event.currentTarget.value)}
403
+ className="min-h-28 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed"
404
+ aria-label="Candidate content"
405
+ />
406
+ <input
407
+ value={draftTags}
408
+ disabled={isUpdating}
409
+ onChange={(event) => setDraftTags(event.currentTarget.value)}
410
+ className="h-7 rounded-md border border-input bg-background px-2 text-xs"
411
+ aria-label="Candidate tags"
412
+ />
413
+ <div className="flex flex-wrap items-center gap-2">
414
+ <span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
415
+ <ShieldCheck className="size-3" />
416
+ Saving reruns Inspector redaction before promotion.
417
+ </span>
418
+ <span className="flex-1" />
419
+ {localError !== null && (
420
+ <span className="text-[10px] text-destructive">{localError}</span>
421
+ )}
422
+ <Button
423
+ type="button"
424
+ variant="ghost"
425
+ size="sm"
426
+ className="h-7 gap-1.5 px-2 text-xs"
427
+ disabled={isUpdating}
428
+ onClick={() => {
429
+ resetDraft();
430
+ setEditing(false);
431
+ }}
432
+ >
433
+ <X className="size-3.5" />
434
+ Cancel
435
+ </Button>
436
+ <Button
437
+ type="button"
438
+ variant="outline"
439
+ size="sm"
440
+ className="h-7 gap-1.5 px-2 text-xs"
441
+ disabled={isUpdating}
442
+ onClick={() => {
443
+ void saveDraft();
444
+ }}
445
+ >
446
+ {isUpdating ? (
447
+ <RefreshCw className="size-3.5 animate-spin" />
448
+ ) : (
449
+ <Save className="size-3.5" />
450
+ )}
451
+ Save
452
+ </Button>
109
453
  </div>
110
454
  </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ return (
460
+ <div className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2">
461
+ <div className="flex min-w-0 items-center gap-2">
462
+ <Badge variant="outline" className="h-5 px-1.5 text-[10px] font-mono">
463
+ {candidate.type}
464
+ </Badge>
465
+ <span className="min-w-0 flex-1 truncate text-xs font-medium" title={candidate.title}>
466
+ {candidate.title}
467
+ </span>
468
+ <Badge
469
+ variant="outline"
470
+ className={cn(
471
+ "h-5 shrink-0 px-1.5 text-[10px] font-mono",
472
+ CANDIDATE_STATUS_CLASSES[candidate.status],
473
+ )}
474
+ >
475
+ {candidateStatusLabel(candidate.status)}
476
+ </Badge>
477
+ </div>
478
+ <p
479
+ className="mt-1 text-[11px] leading-relaxed text-muted-foreground"
480
+ title={candidate.content}
481
+ >
482
+ {previewText(candidate.content, 360)}
483
+ </p>
484
+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
485
+ <span className="inline-flex items-center gap-1">
486
+ <FileSearch className="size-3" />
487
+ {logRangeLabel(candidate.logIds)}
488
+ </span>
489
+ <span className="inline-flex items-center gap-1">
490
+ <ShieldCheck className="size-3" />
491
+ {redactionLabel(candidate)}
492
+ </span>
493
+ {candidate.status === "promoted" && (
494
+ <span className="inline-flex items-center gap-1 text-emerald-500">
495
+ <CircleCheck className="size-3" />
496
+ {candidate.openClawMemoryId ?? "OpenClaw"}
497
+ </span>
498
+ )}
499
+ {candidate.status === "failed" && candidate.error !== null && (
500
+ <span className="inline-flex items-center gap-1 text-destructive">
501
+ <XCircle className="size-3" />
502
+ {candidate.error}
503
+ </span>
504
+ )}
505
+ </div>
506
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
507
+ {candidate.logIds.map((logId) => (
508
+ <a
509
+ key={logId}
510
+ href={`#${getLogAnchor(logId)}`}
511
+ onClick={(event) => {
512
+ event.preventDefault();
513
+ jumpToLog(logId);
514
+ }}
515
+ className="rounded border border-blue-400/25 px-1.5 py-0.5 font-mono text-[10px] text-blue-400 underline-offset-2 transition-colors hover:bg-blue-400/10 hover:text-blue-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
516
+ aria-label={`Jump to evidence log ${String(logId)}`}
517
+ >
518
+ #{logId}
519
+ </a>
520
+ ))}
521
+ <span className="flex-1" />
522
+ {canEdit && (
523
+ <Button
524
+ type="button"
525
+ variant="ghost"
526
+ size="sm"
527
+ className="h-6 gap-1.5 px-2 text-[10px]"
528
+ disabled={isUpdating || isPromoting}
529
+ onClick={() => setEditing(true)}
530
+ >
531
+ <Pencil className="size-3" />
532
+ Review
533
+ </Button>
534
+ )}
535
+ <Button
536
+ type="button"
537
+ variant={canPromote ? "outline" : "ghost"}
538
+ size="sm"
539
+ className="h-6 gap-1.5 px-2 text-[10px]"
540
+ onClick={() => onPromoteCandidate(candidate.id)}
541
+ disabled={!canPromote || isPromoting || isUpdating}
542
+ >
543
+ {isPromoting ? (
544
+ <RefreshCw className="size-3 animate-spin" />
545
+ ) : canPromote ? (
546
+ <UploadCloud className="size-3" />
547
+ ) : (
548
+ <CircleCheck className="size-3" />
549
+ )}
550
+ {candidatePromoteLabel(candidate, isPromoting)}
551
+ </Button>
552
+ </div>
553
+ </div>
554
+ );
555
+ }
556
+
557
+ function CandidateList({
558
+ candidates,
559
+ promotingCandidateIds,
560
+ updatingCandidateIds,
561
+ onPromoteCandidate,
562
+ onUpdateCandidate,
563
+ }: {
564
+ candidates: KnowledgeCandidate[];
565
+ promotingCandidateIds: ReadonlySet<string>;
566
+ updatingCandidateIds: ReadonlySet<string>;
567
+ onPromoteCandidate: (candidateId: string) => void;
568
+ onUpdateCandidate: (candidateId: string, update: CandidateDraftUpdate) => Promise<boolean>;
569
+ }): JSX.Element | null {
570
+ if (candidates.length === 0) return null;
571
+ return (
572
+ <div className="mt-2 grid gap-1.5">
573
+ {candidates.map((candidate) => (
574
+ <CandidateItem
575
+ key={candidate.id}
576
+ candidate={candidate}
577
+ isPromoting={promotingCandidateIds.has(candidate.id)}
578
+ isUpdating={updatingCandidateIds.has(candidate.id)}
579
+ onPromoteCandidate={onPromoteCandidate}
580
+ onUpdateCandidate={onUpdateCandidate}
581
+ />
111
582
  ))}
112
583
  </div>
113
584
  );
@@ -126,11 +597,21 @@ export function AgentTraceSummary({
126
597
  status: "idle",
127
598
  error: null,
128
599
  });
600
+ const [promotingCandidateIds, setPromotingCandidateIds] = useState<ReadonlySet<string>>(
601
+ () => new Set(),
602
+ );
603
+ const [updatingCandidateIds, setUpdatingCandidateIds] = useState<ReadonlySet<string>>(
604
+ () => new Set(),
605
+ );
129
606
  const hasCandidates = candidates.length > 0;
130
607
  const summary = useMemo(
131
608
  () => buildTraceSummary(logs, slowResponseThresholdSeconds, candidates.length),
132
609
  [candidates.length, logs, slowResponseThresholdSeconds],
133
610
  );
611
+ const traceInsights = useMemo(
612
+ () => buildTraceInsights({ logs, scopeId, summary, candidates }),
613
+ [candidates, logs, scopeId, summary],
614
+ );
134
615
  const showElapsedSummary = showRollupMetrics || summary.maxElapsedMs !== null;
135
616
  const timeRange = useMemo(
136
617
  () => formatTimeRange(summary.startedAt, summary.endedAt, timeDisplayFormat),
@@ -157,7 +638,11 @@ export function AgentTraceSummary({
157
638
  const parsed = await parseJsonResponse(response, CandidateResponseSchema);
158
639
  setCandidates(parsed.candidates);
159
640
  setCandidatesExpanded(parsed.candidates.length > 0);
160
- setCandidateState({ status: "ready", error: null });
641
+ setCandidateState(
642
+ parsed.candidates.length > 0
643
+ ? { status: "ready", error: null }
644
+ : { status: "failed", error: "No candidate was generated for this trace scope." },
645
+ );
161
646
  } catch (error) {
162
647
  setCandidateState({
163
648
  status: "failed",
@@ -167,6 +652,113 @@ export function AgentTraceSummary({
167
652
  })();
168
653
  }, [candidateState.status, logs.length, scopeId]);
169
654
 
655
+ const promoteCandidate = useCallback((candidateId: string) => {
656
+ setPromotingCandidateIds((current) => {
657
+ if (current.has(candidateId)) return current;
658
+ const next = new Set(current);
659
+ next.add(candidateId);
660
+ return next;
661
+ });
662
+
663
+ void (async () => {
664
+ try {
665
+ const response = await fetch(
666
+ `/api/knowledge/candidates/${encodeURIComponent(candidateId)}/promote`,
667
+ { method: "POST" },
668
+ );
669
+ const raw: unknown = await response.json().catch(() => null);
670
+ const parsed = CandidatePromotionResponseSchema.safeParse(raw);
671
+ if (response.ok && parsed.success) {
672
+ setCandidates((current) =>
673
+ current.map((candidate) =>
674
+ candidate.id === parsed.data.candidate.id ? parsed.data.candidate : candidate,
675
+ ),
676
+ );
677
+ setCandidateState({ status: "ready", error: null });
678
+ return;
679
+ }
680
+
681
+ const failure = CandidatePromotionFailureResponseSchema.safeParse(raw);
682
+ if (failure.success && failure.data.candidate !== undefined) {
683
+ setCandidates((current) =>
684
+ current.map((candidate) =>
685
+ candidate.id === failure.data.candidate?.id ? failure.data.candidate : candidate,
686
+ ),
687
+ );
688
+ }
689
+ setCandidateState({
690
+ status: "failed",
691
+ error: failure.success
692
+ ? (failure.data.error ?? "Candidate promotion failed")
693
+ : `Candidate promotion failed with ${String(response.status)}`,
694
+ });
695
+ } catch (error) {
696
+ setCandidateState({
697
+ status: "failed",
698
+ error: error instanceof Error ? error.message : "Candidate promotion failed",
699
+ });
700
+ } finally {
701
+ setPromotingCandidateIds((current) => {
702
+ const next = new Set(current);
703
+ next.delete(candidateId);
704
+ return next;
705
+ });
706
+ }
707
+ })();
708
+ }, []);
709
+
710
+ const updateCandidate = useCallback(
711
+ async (candidateId: string, update: CandidateDraftUpdate): Promise<boolean> => {
712
+ setUpdatingCandidateIds((current) => {
713
+ if (current.has(candidateId)) return current;
714
+ const next = new Set(current);
715
+ next.add(candidateId);
716
+ return next;
717
+ });
718
+
719
+ try {
720
+ const response = await fetch(
721
+ `/api/knowledge/candidates/${encodeURIComponent(candidateId)}`,
722
+ {
723
+ method: "PATCH",
724
+ headers: { "Content-Type": "application/json" },
725
+ body: JSON.stringify(update),
726
+ },
727
+ );
728
+ if (!response.ok) {
729
+ const message = await readApiError(
730
+ response,
731
+ `Candidate update failed with ${String(response.status)}`,
732
+ );
733
+ setCandidateState({ status: "failed", error: message });
734
+ return false;
735
+ }
736
+
737
+ const parsed = await parseJsonResponse(response, CandidateUpdateResponseSchema);
738
+ setCandidates((current) =>
739
+ current.map((candidate) =>
740
+ candidate.id === parsed.candidate.id ? parsed.candidate : candidate,
741
+ ),
742
+ );
743
+ setCandidateState({ status: "ready", error: null });
744
+ return true;
745
+ } catch (error) {
746
+ setCandidateState({
747
+ status: "failed",
748
+ error: error instanceof Error ? error.message : "Candidate update failed",
749
+ });
750
+ return false;
751
+ } finally {
752
+ setUpdatingCandidateIds((current) => {
753
+ const next = new Set(current);
754
+ next.delete(candidateId);
755
+ return next;
756
+ });
757
+ }
758
+ },
759
+ [],
760
+ );
761
+
170
762
  if (logs.length === 0) return null;
171
763
 
172
764
  return (
@@ -194,7 +786,7 @@ export function AgentTraceSummary({
194
786
  <span className="inline-flex items-center gap-1.5 text-muted-foreground">
195
787
  <Zap className="size-3.5 text-purple-400" />
196
788
  <span className="font-mono">
197
- +{formatTokens(summary.totalCacheCreationInputTokens)} / ~
789
+ KV Cache +{formatTokens(summary.totalCacheCreationInputTokens)} / ~
198
790
  {formatTokens(summary.totalCacheReadInputTokens)}
199
791
  </span>
200
792
  </span>
@@ -270,7 +862,16 @@ export function AgentTraceSummary({
270
862
  {candidateState.status === "failed" && (
271
863
  <p className="mt-2 text-xs text-destructive">{candidateState.error}</p>
272
864
  )}
273
- {candidatesExpanded && <CandidateList candidates={candidates} />}
865
+ <TraceInsights insights={traceInsights} />
866
+ {candidatesExpanded && (
867
+ <CandidateList
868
+ candidates={candidates}
869
+ promotingCandidateIds={promotingCandidateIds}
870
+ updatingCandidateIds={updatingCandidateIds}
871
+ onPromoteCandidate={promoteCandidate}
872
+ onUpdateCandidate={updateCandidate}
873
+ />
874
+ )}
274
875
  </section>
275
876
  );
276
877
  }