@tonyclaw/agent-inspector 2.0.1 → 2.0.3

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/cli.js +344 -53
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-sVLGhCO3.js → CompareDrawer-D5A4bTfV.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-Da0jpBkp.js +101 -0
  5. package/.output/public/assets/{ReplayDialog-DxbFUqNW.js → ReplayDialog-CxUk_TF0.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CSmGQa_g.js → RequestAnatomy-DIlzjgjJ.js} +1 -1
  7. package/.output/public/assets/ResponseView-DQCuKJ1G.js +1 -0
  8. package/.output/public/assets/{StreamingChunkSequence-BzqpY0TN.js → StreamingChunkSequence-DHk4SGGL.js} +1 -1
  9. package/.output/public/assets/_sessionId-dY1TTl7N.js +1 -0
  10. package/.output/public/assets/index-D7wwbwly.css +1 -0
  11. package/.output/public/assets/index-FqQZbfl2.js +1 -0
  12. package/.output/public/assets/{json-viewer-CKNMihlh.js → json-viewer-BbU0n8eM.js} +1 -1
  13. package/.output/public/assets/{main-yWf8dv9w.js → main-CZT_F-gu.js} +2 -2
  14. package/.output/server/_libs/lucide-react.mjs +8 -8
  15. package/.output/server/{_sessionId-DfHd0gd8.mjs → _sessionId-B-s9P7fJ.mjs} +2 -2
  16. package/.output/server/_ssr/{CompareDrawer-DGYAUWgF.mjs → CompareDrawer-C08L3UOO.mjs} +4 -4
  17. package/.output/server/_ssr/{ProxyViewerContainer-fawglkTo.mjs → ProxyViewerContainer-CMWl3Ijy.mjs} +414 -70
  18. package/.output/server/_ssr/{ReplayDialog-B4vlKa2W.mjs → ReplayDialog-CPDo9_G5.mjs} +4 -4
  19. package/.output/server/_ssr/{RequestAnatomy-BNQvEIZK.mjs → RequestAnatomy-D9wt_K1E.mjs} +3 -3
  20. package/.output/server/_ssr/{ResponseView-X6X6G16_.mjs → ResponseView-DXaL7nY3.mjs} +4 -4
  21. package/.output/server/_ssr/{StreamingChunkSequence-BPVN3MnF.mjs → StreamingChunkSequence-B_hudZyb.mjs} +3 -3
  22. package/.output/server/_ssr/{index-CXmpc2X5.mjs → index-CuE_BN86.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-3XC3eq4R.mjs → json-viewer-Ci6kkjde.mjs} +2 -2
  25. package/.output/server/_ssr/{router-C0B2qvIM.mjs → router-BemxgIg7.mjs} +402 -131
  26. package/.output/server/{_tanstack-start-manifest_v-7tfsmd2I.mjs → _tanstack-start-manifest_v--L1_b4sd.mjs} +1 -1
  27. package/.output/server/index.mjs +62 -62
  28. package/README.md +50 -7
  29. package/package.json +3 -2
  30. package/scripts/setup-codex-skill.mjs +38 -0
  31. package/scripts/setup-windows-runtime.mjs +4 -3
  32. package/src/cli/onboard.ts +175 -68
  33. package/src/cli/templates/codex-skill-onboard.ts +210 -0
  34. package/src/components/providers/ProviderCard.tsx +2 -27
  35. package/src/components/providers/ProvidersPanel.tsx +16 -0
  36. package/src/components/proxy-viewer/AgentTraceSummary.tsx +218 -0
  37. package/src/components/proxy-viewer/ConversationGroup.tsx +6 -0
  38. package/src/components/proxy-viewer/ToolTraceEvents.tsx +33 -0
  39. package/src/components/proxy-viewer/TurnGroup.tsx +11 -1
  40. package/src/components/proxy-viewer/viewerState.ts +177 -0
  41. package/src/knowledge/openclawClient.ts +34 -5
  42. package/src/knowledge/openclawGatewayClient.ts +237 -0
  43. package/src/knowledge/openclawMarkdown.ts +146 -0
  44. package/src/lib/providerTestPrompt.ts +78 -0
  45. package/src/proxy/chunkStorage.ts +3 -4
  46. package/src/proxy/logger.ts +8 -15
  47. package/src/proxy/store.ts +8 -16
  48. package/src/routes/api/providers.$providerId.test.log.ts +7 -99
  49. package/.output/public/assets/ProxyViewerContainer-p9QvzZ6U.js +0 -101
  50. package/.output/public/assets/ResponseView-B5f89c8Z.js +0 -1
  51. package/.output/public/assets/_sessionId-BF7ftHV3.js +0 -1
  52. package/.output/public/assets/index-BU0PpLby.js +0 -1
  53. package/.output/public/assets/index-CpWG2hFn.css +0 -1
@@ -0,0 +1,218 @@
1
+ import { useCallback, useMemo, useState, type JSX } from "react";
2
+ import { AlertTriangle, Brain, Clock, Loader2, MessageSquare, Wrench, Zap } from "lucide-react";
3
+ import { z } from "zod";
4
+ import { formatTokens } from "../../lib/utils";
5
+ import { parseJsonResponse, readApiError } from "../../lib/apiClient";
6
+ import { KnowledgeCandidateSchema, type KnowledgeCandidate } from "../../knowledge/types";
7
+ import type { CapturedLog } from "../../proxy/schemas";
8
+ import { Badge } from "../ui/badge";
9
+ import { Button } from "../ui/button";
10
+ import { buildTraceSummary } from "./viewerState";
11
+
12
+ const CandidateResponseSchema = z.object({
13
+ candidates: z.array(KnowledgeCandidateSchema),
14
+ });
15
+
16
+ type CandidateLoadState =
17
+ | { status: "idle"; error: null }
18
+ | { status: "loading"; error: null }
19
+ | { status: "ready"; error: null }
20
+ | { status: "failed"; error: string };
21
+
22
+ type AgentTraceSummaryProps = {
23
+ logs: CapturedLog[];
24
+ scopeId: string;
25
+ slowResponseThresholdSeconds: number;
26
+ };
27
+
28
+ function formatElapsed(ms: number | null): string {
29
+ if (ms === null) return "-";
30
+ if (ms < 1000) return `${String(ms)}ms`;
31
+ return `${(ms / 1000).toFixed(1)}s`;
32
+ }
33
+
34
+ function formatTimeRange(startedAt: string | null, endedAt: string | null): string | null {
35
+ if (startedAt === null || endedAt === null) return null;
36
+ const format = (iso: string): string =>
37
+ new Date(iso).toLocaleTimeString([], {
38
+ hour: "2-digit",
39
+ minute: "2-digit",
40
+ second: "2-digit",
41
+ });
42
+ return `${format(startedAt)} - ${format(endedAt)}`;
43
+ }
44
+
45
+ function scrollToLog(logId: number): void {
46
+ const target = document.getElementById(`log-${String(logId)}`);
47
+ if (!(target instanceof HTMLElement)) return;
48
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
49
+ target.focus({ preventScroll: true });
50
+ if (target.getAttribute("data-nav-action") === "expand") {
51
+ target.click();
52
+ }
53
+ }
54
+
55
+ function CandidateList({ candidates }: { candidates: KnowledgeCandidate[] }): JSX.Element | null {
56
+ if (candidates.length === 0) return null;
57
+ return (
58
+ <div className="mt-2 grid gap-1.5">
59
+ {candidates.map((candidate) => (
60
+ <div
61
+ key={candidate.id}
62
+ className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2"
63
+ >
64
+ <div className="flex min-w-0 items-center gap-2">
65
+ <Badge variant="outline" className="h-5 px-1.5 text-[10px] font-mono">
66
+ {candidate.type}
67
+ </Badge>
68
+ <span className="min-w-0 flex-1 truncate text-xs font-medium" title={candidate.title}>
69
+ {candidate.title}
70
+ </span>
71
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground">
72
+ {candidate.status}
73
+ </span>
74
+ </div>
75
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
76
+ {candidate.logIds.map((logId) => (
77
+ <button
78
+ key={logId}
79
+ type="button"
80
+ onClick={() => scrollToLog(logId)}
81
+ className="rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-blue-400 transition-colors hover:bg-muted hover:text-blue-300"
82
+ >
83
+ #{logId}
84
+ </button>
85
+ ))}
86
+ </div>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ export function AgentTraceSummary({
94
+ logs,
95
+ scopeId,
96
+ slowResponseThresholdSeconds,
97
+ }: AgentTraceSummaryProps): JSX.Element | null {
98
+ const [candidates, setCandidates] = useState<KnowledgeCandidate[]>([]);
99
+ const [candidateState, setCandidateState] = useState<CandidateLoadState>({
100
+ status: "idle",
101
+ error: null,
102
+ });
103
+ const summary = useMemo(
104
+ () => buildTraceSummary(logs, slowResponseThresholdSeconds, candidates.length),
105
+ [candidates.length, logs, slowResponseThresholdSeconds],
106
+ );
107
+ const timeRange = useMemo(
108
+ () => formatTimeRange(summary.startedAt, summary.endedAt),
109
+ [summary.endedAt, summary.startedAt],
110
+ );
111
+
112
+ const createCandidates = useCallback(() => {
113
+ if (logs.length === 0 || candidateState.status === "loading") return;
114
+ setCandidateState({ status: "loading", error: null });
115
+ void (async () => {
116
+ try {
117
+ const response = await fetch(
118
+ `/api/knowledge/sessions/${encodeURIComponent(scopeId)}/candidates`,
119
+ { method: "POST" },
120
+ );
121
+ if (!response.ok) {
122
+ const message = await readApiError(
123
+ response,
124
+ `Candidate generation failed with ${String(response.status)}`,
125
+ );
126
+ setCandidateState({ status: "failed", error: message });
127
+ return;
128
+ }
129
+ const parsed = await parseJsonResponse(response, CandidateResponseSchema);
130
+ setCandidates(parsed.candidates);
131
+ setCandidateState({ status: "ready", error: null });
132
+ } catch (error) {
133
+ setCandidateState({
134
+ status: "failed",
135
+ error: error instanceof Error ? error.message : "Candidate response was invalid",
136
+ });
137
+ }
138
+ })();
139
+ }, [candidateState.status, logs.length, scopeId]);
140
+
141
+ if (logs.length === 0) return null;
142
+
143
+ return (
144
+ <section className="mb-2 rounded-lg border border-border bg-muted/10 px-3 py-2">
145
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs">
146
+ <span className="inline-flex items-center gap-1.5 font-semibold text-foreground">
147
+ <MessageSquare className="size-3.5 text-blue-400" />
148
+ {summary.llmCallCount} LLM
149
+ </span>
150
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
151
+ <Wrench className="size-3.5 text-amber-400" />
152
+ {summary.toolCallCount} tools
153
+ </span>
154
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
155
+ <Zap className="size-3.5 text-emerald-400" />
156
+ <span className="font-mono">
157
+ {formatTokens(summary.totalInputTokens)} / {formatTokens(summary.totalOutputTokens)}
158
+ </span>
159
+ </span>
160
+ {(summary.totalCacheCreationInputTokens > 0 || summary.totalCacheReadInputTokens > 0) && (
161
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
162
+ <Zap className="size-3.5 text-purple-400" />
163
+ <span className="font-mono">
164
+ +{formatTokens(summary.totalCacheCreationInputTokens)} / ~
165
+ {formatTokens(summary.totalCacheReadInputTokens)}
166
+ </span>
167
+ </span>
168
+ )}
169
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
170
+ <Clock className="size-3.5" />
171
+ <span className="font-mono">{formatElapsed(summary.totalElapsedMs)}</span>
172
+ {summary.maxElapsedMs !== null && (
173
+ <span className="font-mono text-muted-foreground/70">
174
+ max {formatElapsed(summary.maxElapsedMs)}
175
+ </span>
176
+ )}
177
+ </span>
178
+ {timeRange !== null && (
179
+ <span className="font-mono text-muted-foreground/70">{timeRange}</span>
180
+ )}
181
+ {(summary.failedCallCount > 0 ||
182
+ summary.pendingCallCount > 0 ||
183
+ summary.slowCallCount > 0) && (
184
+ <span className="inline-flex items-center gap-1.5 text-amber-400">
185
+ <AlertTriangle className="size-3.5" />
186
+ <span className="font-mono">
187
+ {summary.failedCallCount} fail / {summary.pendingCallCount} pending /{" "}
188
+ {summary.slowCallCount} slow
189
+ </span>
190
+ </span>
191
+ )}
192
+ <span className="flex-1" />
193
+ <Badge variant="outline" className="h-6 px-2 text-[10px] font-mono">
194
+ {summary.knowledgeCandidateCount} memory
195
+ </Badge>
196
+ <Button
197
+ type="button"
198
+ variant="outline"
199
+ size="sm"
200
+ className="h-7 gap-1.5 px-2 text-xs"
201
+ onClick={createCandidates}
202
+ disabled={candidateState.status === "loading"}
203
+ >
204
+ {candidateState.status === "loading" ? (
205
+ <Loader2 className="size-3.5 animate-spin" />
206
+ ) : (
207
+ <Brain className="size-3.5" />
208
+ )}
209
+ Candidate
210
+ </Button>
211
+ </div>
212
+ {candidateState.status === "failed" && (
213
+ <p className="mt-2 text-xs text-destructive">{candidateState.error}</p>
214
+ )}
215
+ <CandidateList candidates={candidates} />
216
+ </section>
217
+ );
218
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type ConversationGroupData,
9
9
  } from "./ConversationHeader";
10
10
  import { TurnGroup } from "./TurnGroup";
11
+ import { AgentTraceSummary } from "./AgentTraceSummary";
11
12
  import type { CacheTrendEntry } from "./cacheTrend";
12
13
  import { buildTurnGroups, shouldRenderConversationContent } from "./viewerState";
13
14
 
@@ -87,6 +88,11 @@ export const ConversationGroup = memo(function ({
87
88
 
88
89
  {shouldRenderConversationContent(standalone, expanded) && (
89
90
  <div>
91
+ <AgentTraceSummary
92
+ logs={group.logs}
93
+ scopeId={group.conversationId}
94
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
95
+ />
90
96
  {turnGroups.map((tg) => (
91
97
  <TurnGroup
92
98
  key={tg.turnIndex}
@@ -0,0 +1,33 @@
1
+ import { type JSX } from "react";
2
+ import { ChevronRight, Wrench } from "lucide-react";
3
+ import type { ToolTraceEvent } from "./viewerState";
4
+
5
+ type ToolTraceEventsProps = {
6
+ events: ToolTraceEvent[];
7
+ };
8
+
9
+ export function ToolTraceEvents({ events }: ToolTraceEventsProps): JSX.Element | null {
10
+ if (events.length === 0) return null;
11
+ return (
12
+ <div className="mx-3 mb-2 grid gap-1.5">
13
+ {events.map((event) => (
14
+ <div
15
+ key={event.id}
16
+ className="flex min-w-0 items-center gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 text-xs"
17
+ >
18
+ <Wrench className="size-3.5 shrink-0 text-amber-400" />
19
+ <span className="font-mono font-semibold text-amber-300">{event.name}</span>
20
+ <span className="font-mono text-muted-foreground">#{event.logId}</span>
21
+ {event.argumentsPreview !== null && (
22
+ <>
23
+ <ChevronRight className="size-3 shrink-0 text-muted-foreground/60" />
24
+ <span className="min-w-0 truncate font-mono text-muted-foreground">
25
+ {event.argumentsPreview}
26
+ </span>
27
+ </>
28
+ )}
29
+ </div>
30
+ ))}
31
+ </div>
32
+ );
33
+ }
@@ -9,7 +9,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/
9
9
  import type { CacheTrendEntry } from "./cacheTrend";
10
10
  import { LogEntry } from "./LogEntry";
11
11
  import { ThreadConnector } from "./ThreadConnector";
12
- import { isTurnCollapsible, type TurnEntry } from "./viewerState";
12
+ import { ToolTraceEvents } from "./ToolTraceEvents";
13
+ import { extractToolTraceEvents, isTurnCollapsible, type TurnEntry } from "./viewerState";
13
14
 
14
15
  function formatElapsed(ms: number): string {
15
16
  if (ms < 1000) return `${ms}ms`;
@@ -106,6 +107,14 @@ export const TurnGroup = memo(function TurnGroup({
106
107
  aggregate.maxElapsed !== null &&
107
108
  slowResponseThresholdSeconds > 0 &&
108
109
  aggregate.maxElapsed > slowResponseThresholdSeconds * 1000;
110
+ const toolEventsByLogId = useMemo(() => {
111
+ const events = new Map<number, ReturnType<typeof extractToolTraceEvents>>();
112
+ for (const entry of entries) {
113
+ const extracted = extractToolTraceEvents(entry.log);
114
+ if (extracted.length > 0) events.set(entry.log.id, extracted);
115
+ }
116
+ return events;
117
+ }, [entries]);
109
118
 
110
119
  // ResizeObserver → re-render connectors when any LogEntry height changes
111
120
  const [layoutVersion, setLayoutVersion] = useState(0);
@@ -327,6 +336,7 @@ export const TurnGroup = memo(function TurnGroup({
327
336
  comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
328
337
  }
329
338
  />
339
+ <ToolTraceEvents events={toolEventsByLogId.get(log.id) ?? []} />
330
340
  </div>
331
341
  </div>
332
342
  );
@@ -1,4 +1,5 @@
1
1
  import { extractStopReason, isTurnBoundary, type StopReason } from "../../lib/stopReason";
2
+ import { safeGetOwnProperty } from "../../lib/objectUtils";
2
3
  import type { CapturedLog } from "../../proxy/schemas";
3
4
  import { resolveLogFormat } from "./log-formats";
4
5
 
@@ -16,6 +17,34 @@ type ConversationLike = {
16
17
  logs: CapturedLog[];
17
18
  };
18
19
 
20
+ export type TraceSummary = {
21
+ llmCallCount: number;
22
+ toolCallCount: number;
23
+ failedCallCount: number;
24
+ pendingCallCount: number;
25
+ slowCallCount: number;
26
+ totalInputTokens: number;
27
+ totalOutputTokens: number;
28
+ totalCacheCreationInputTokens: number;
29
+ totalCacheReadInputTokens: number;
30
+ totalElapsedMs: number;
31
+ maxElapsedMs: number | null;
32
+ startedAt: string | null;
33
+ endedAt: string | null;
34
+ knowledgeCandidateCount: number;
35
+ };
36
+
37
+ export type ToolTraceEvent = {
38
+ id: string;
39
+ logId: number;
40
+ index: number;
41
+ provider: "anthropic" | "openai";
42
+ name: string;
43
+ argumentsPreview: string | null;
44
+ };
45
+
46
+ const PREVIEW_LIMIT = 180;
47
+
19
48
  export function shouldRenderConversationContent(standalone: boolean, expanded: boolean): boolean {
20
49
  return standalone || expanded;
21
50
  }
@@ -64,3 +93,151 @@ export function buildValidPredecessors(groups: ConversationLike[]): Map<number,
64
93
 
65
94
  return predecessors;
66
95
  }
96
+
97
+ function parseJsonResponse(responseText: string | null): unknown {
98
+ if (responseText === null) return null;
99
+ try {
100
+ const parsed: unknown = JSON.parse(responseText);
101
+ if (typeof parsed === "string") {
102
+ return JSON.parse(parsed);
103
+ }
104
+ return parsed;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function previewValue(value: unknown): string | null {
111
+ if (value === undefined || value === null) return null;
112
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
113
+ if (raw === undefined) return null;
114
+ const normalized = raw.replace(/\s+/g, " ").trim();
115
+ if (normalized.length === 0) return null;
116
+ return normalized.length > PREVIEW_LIMIT
117
+ ? `${normalized.slice(0, PREVIEW_LIMIT - 1)}...`
118
+ : normalized;
119
+ }
120
+
121
+ function extractAnthropicToolTraceEvents(log: CapturedLog): ToolTraceEvent[] {
122
+ const parsed = parseJsonResponse(log.responseText);
123
+ const content = safeGetOwnProperty(parsed, "content");
124
+ if (!Array.isArray(content)) return [];
125
+
126
+ const events: ToolTraceEvent[] = [];
127
+ for (const block of content) {
128
+ const type = safeGetOwnProperty(block, "type");
129
+ if (type !== "tool_use") continue;
130
+ const name = safeGetOwnProperty(block, "name");
131
+ if (typeof name !== "string" || name.length === 0) continue;
132
+ events.push({
133
+ id: `${String(log.id)}-anthropic-tool-${String(events.length)}`,
134
+ logId: log.id,
135
+ index: events.length,
136
+ provider: "anthropic",
137
+ name,
138
+ argumentsPreview: previewValue(safeGetOwnProperty(block, "input")),
139
+ });
140
+ }
141
+ return events;
142
+ }
143
+
144
+ function extractOpenAIToolTraceEvents(log: CapturedLog): ToolTraceEvent[] {
145
+ const parsed = parseJsonResponse(log.responseText);
146
+ const choices = safeGetOwnProperty(parsed, "choices");
147
+ if (!Array.isArray(choices)) return [];
148
+
149
+ const events: ToolTraceEvent[] = [];
150
+ for (const choice of choices) {
151
+ const message = safeGetOwnProperty(choice, "message");
152
+ const toolCalls = safeGetOwnProperty(message, "tool_calls");
153
+ if (!Array.isArray(toolCalls)) continue;
154
+ for (const call of toolCalls) {
155
+ const fn = safeGetOwnProperty(call, "function");
156
+ const name = safeGetOwnProperty(fn, "name");
157
+ if (typeof name !== "string" || name.length === 0) continue;
158
+ events.push({
159
+ id: `${String(log.id)}-openai-tool-${String(events.length)}`,
160
+ logId: log.id,
161
+ index: events.length,
162
+ provider: "openai",
163
+ name,
164
+ argumentsPreview: previewValue(safeGetOwnProperty(fn, "arguments")),
165
+ });
166
+ }
167
+ }
168
+ return events;
169
+ }
170
+
171
+ export function extractToolTraceEvents(log: CapturedLog): ToolTraceEvent[] {
172
+ const format = resolveLogFormat(log);
173
+ switch (format) {
174
+ case "anthropic":
175
+ return extractAnthropicToolTraceEvents(log);
176
+ case "openai":
177
+ return extractOpenAIToolTraceEvents(log);
178
+ case "unknown":
179
+ return [];
180
+ }
181
+ }
182
+
183
+ export function buildTraceSummary(
184
+ logs: CapturedLog[],
185
+ slowResponseThresholdSeconds: number,
186
+ knowledgeCandidateCount = 0,
187
+ ): TraceSummary {
188
+ let failedCallCount = 0;
189
+ let pendingCallCount = 0;
190
+ let slowCallCount = 0;
191
+ let totalInputTokens = 0;
192
+ let totalOutputTokens = 0;
193
+ let totalCacheCreationInputTokens = 0;
194
+ let totalCacheReadInputTokens = 0;
195
+ let totalElapsedMs = 0;
196
+ let maxElapsedMs: number | null = null;
197
+ let toolCallCount = 0;
198
+
199
+ for (const log of logs) {
200
+ if (log.responseStatus === null) {
201
+ pendingCallCount += 1;
202
+ } else if (log.responseStatus >= 400) {
203
+ failedCallCount += 1;
204
+ }
205
+ if (
206
+ log.elapsedMs !== null &&
207
+ slowResponseThresholdSeconds > 0 &&
208
+ log.elapsedMs > slowResponseThresholdSeconds * 1000
209
+ ) {
210
+ slowCallCount += 1;
211
+ }
212
+ if (log.inputTokens !== null) totalInputTokens += log.inputTokens;
213
+ if (log.outputTokens !== null) totalOutputTokens += log.outputTokens;
214
+ if (log.cacheCreationInputTokens !== null) {
215
+ totalCacheCreationInputTokens += log.cacheCreationInputTokens;
216
+ }
217
+ if (log.cacheReadInputTokens !== null) {
218
+ totalCacheReadInputTokens += log.cacheReadInputTokens;
219
+ }
220
+ if (log.elapsedMs !== null) {
221
+ totalElapsedMs += log.elapsedMs;
222
+ maxElapsedMs = maxElapsedMs === null ? log.elapsedMs : Math.max(maxElapsedMs, log.elapsedMs);
223
+ }
224
+ toolCallCount += extractToolTraceEvents(log).length;
225
+ }
226
+
227
+ return {
228
+ llmCallCount: logs.length,
229
+ toolCallCount,
230
+ failedCallCount,
231
+ pendingCallCount,
232
+ slowCallCount,
233
+ totalInputTokens,
234
+ totalOutputTokens,
235
+ totalCacheCreationInputTokens,
236
+ totalCacheReadInputTokens,
237
+ totalElapsedMs,
238
+ maxElapsedMs,
239
+ startedAt: logs[0]?.timestamp ?? null,
240
+ endedAt: logs[logs.length - 1]?.timestamp ?? null,
241
+ knowledgeCandidateCount,
242
+ };
243
+ }
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import type { KnowledgeCandidate, KnowledgeSearchResponse, OpenClawMemoryPayload } from "./types";
3
3
  import { KnowledgeSearchResponseSchema, KnowledgeSearchResultSchema } from "./types";
4
+ import { isOpenClawGatewayConfigured, searchOpenClawGateway } from "./openclawGatewayClient";
5
+ import { isOpenClawFileBridgeConfigured, writeCandidateToOpenClawMemory } from "./openclawMarkdown";
4
6
  import { redactCandidate } from "./redactor";
5
7
 
6
8
  const OpenClawWriteResponseSchema = z.object({
@@ -18,12 +20,16 @@ export type PromoteResult =
18
20
  error: string;
19
21
  };
20
22
 
21
- function getEndpoint(): string | null {
23
+ function getLegacyEndpoint(): string | null {
22
24
  const endpoint = process.env["OPENCLAW_MEMORY_URL"] ?? process.env["OPENCLAW_API_URL"];
23
25
  if (endpoint === undefined || endpoint.trim() === "") return null;
24
26
  return endpoint.trim();
25
27
  }
26
28
 
29
+ function isLegacyBackendConfigured(): boolean {
30
+ return getLegacyEndpoint() !== null;
31
+ }
32
+
27
33
  function buildMemoryUrl(endpoint: string): string {
28
34
  return endpoint.endsWith("/memories") ? endpoint : `${endpoint.replace(/\/$/, "")}/memories`;
29
35
  }
@@ -61,11 +67,21 @@ export function buildOpenClawPayload(candidate: KnowledgeCandidate): OpenClawMem
61
67
  }
62
68
 
63
69
  export async function promoteToOpenClaw(candidate: KnowledgeCandidate): Promise<PromoteResult> {
64
- const endpoint = getEndpoint();
70
+ if (isOpenClawFileBridgeConfigured()) {
71
+ const result = await writeCandidateToOpenClawMemory(candidate);
72
+ if (result.success) {
73
+ return { success: true, memoryId: result.memoryId };
74
+ }
75
+ return { success: false, error: result.error };
76
+ }
77
+
78
+ const endpoint = getLegacyEndpoint();
65
79
  if (endpoint === null) {
66
80
  return {
67
81
  success: false,
68
- error: "OpenClaw memory backend is not configured. Set OPENCLAW_MEMORY_URL.",
82
+ error:
83
+ "OpenClaw memory backend is not configured. Set OPENCLAW_WORKSPACE_DIR " +
84
+ "for file-backed memory or OPENCLAW_MEMORY_URL for a legacy HTTP backend.",
69
85
  };
70
86
  }
71
87
 
@@ -91,11 +107,20 @@ export async function searchOpenClaw(
91
107
  query: string,
92
108
  project?: string,
93
109
  ): Promise<KnowledgeSearchResponse> {
94
- const endpoint = getEndpoint();
110
+ if (isOpenClawGatewayConfigured()) {
111
+ return await searchOpenClawGateway(query, project);
112
+ }
113
+
114
+ const endpoint = getLegacyEndpoint();
95
115
  if (endpoint === null) {
116
+ const workspaceHint = isOpenClawFileBridgeConfigured()
117
+ ? " OPENCLAW_WORKSPACE_DIR is configured for writes, but search requires OPENCLAW_GATEWAY_URL."
118
+ : "";
96
119
  return {
97
120
  results: [],
98
- warning: "OpenClaw memory backend is not configured. Set OPENCLAW_MEMORY_URL.",
121
+ warning:
122
+ "OpenClaw search backend is not configured. Set OPENCLAW_GATEWAY_URL " +
123
+ `or OPENCLAW_MEMORY_URL.${workspaceHint}`,
99
124
  };
100
125
  }
101
126
 
@@ -116,3 +141,7 @@ export async function searchOpenClaw(
116
141
  }
117
142
  return { results: [], warning: "OpenClaw search returned an unparseable response." };
118
143
  }
144
+
145
+ export function isOpenClawPromotionConfigured(): boolean {
146
+ return isOpenClawFileBridgeConfigured() || isLegacyBackendConfigured();
147
+ }