@castlekit/castle 0.3.0 → 0.3.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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/install.sh +20 -1
  3. package/package.json +17 -2
  4. package/src/app/api/openclaw/agents/route.ts +7 -1
  5. package/src/app/api/openclaw/chat/channels/route.ts +6 -3
  6. package/src/app/api/openclaw/chat/route.ts +17 -6
  7. package/src/app/api/openclaw/chat/search/route.ts +2 -1
  8. package/src/app/api/openclaw/config/route.ts +2 -0
  9. package/src/app/api/openclaw/events/route.ts +23 -8
  10. package/src/app/api/openclaw/ping/route.ts +5 -0
  11. package/src/app/api/openclaw/session/context/route.ts +163 -0
  12. package/src/app/api/openclaw/session/status/route.ts +179 -11
  13. package/src/app/api/openclaw/sessions/route.ts +2 -0
  14. package/src/app/chat/[channelId]/page.tsx +115 -35
  15. package/src/app/globals.css +10 -0
  16. package/src/app/page.tsx +10 -8
  17. package/src/components/chat/chat-input.tsx +23 -5
  18. package/src/components/chat/message-bubble.tsx +29 -13
  19. package/src/components/chat/message-list.tsx +238 -80
  20. package/src/components/chat/session-stats-panel.tsx +391 -86
  21. package/src/components/providers/search-provider.tsx +33 -4
  22. package/src/lib/db/index.ts +12 -2
  23. package/src/lib/db/queries.ts +199 -72
  24. package/src/lib/db/schema.ts +4 -0
  25. package/src/lib/gateway-connection.ts +24 -3
  26. package/src/lib/hooks/use-chat.ts +219 -241
  27. package/src/lib/hooks/use-compaction-events.ts +132 -0
  28. package/src/lib/hooks/use-context-boundary.ts +82 -0
  29. package/src/lib/hooks/use-openclaw.ts +44 -57
  30. package/src/lib/hooks/use-search.ts +1 -0
  31. package/src/lib/hooks/use-session-stats.ts +4 -1
  32. package/src/lib/sse-singleton.ts +184 -0
  33. package/src/lib/types/chat.ts +22 -6
  34. package/src/lib/db/__tests__/queries.test.ts +0 -318
  35. package/vitest.config.ts +0 -13
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { subscribe, type SSEEvent } from "@/lib/sse-singleton";
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ export interface CompactionEvent {
11
+ phase: "start" | "end";
12
+ willRetry?: boolean;
13
+ timestamp: number;
14
+ }
15
+
16
+ interface UseCompactionEventsOptions {
17
+ /** The session key to monitor. Null disables monitoring. */
18
+ sessionKey: string | null;
19
+ /** Callback fired when compaction completes (phase: "end"). */
20
+ onCompactionComplete?: () => void;
21
+ }
22
+
23
+ interface UseCompactionEventsReturn {
24
+ /** Whether a compaction is currently in progress. */
25
+ isCompacting: boolean;
26
+ /** The last compaction event received. */
27
+ lastCompaction: CompactionEvent | null;
28
+ /** Number of compactions observed in this session (since mount). */
29
+ compactionCount: number;
30
+ /** Whether to show the compaction banner (auto-dismissed after 8s). */
31
+ showBanner: boolean;
32
+ /** Manually dismiss the banner. */
33
+ dismissBanner: () => void;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Hook
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Subscribes to real-time compaction events from the Gateway via SSE singleton.
42
+ *
43
+ * Compaction events arrive as agent events with `stream: "compaction"`:
44
+ * { stream: "compaction", data: { phase: "start"|"end", willRetry?: boolean } }
45
+ *
46
+ * These are forwarded through the SSE system as events with type starting
47
+ * with "agent." or as top-level events. We subscribe to "*" and filter.
48
+ */
49
+ export function useCompactionEvents({
50
+ sessionKey,
51
+ onCompactionComplete,
52
+ }: UseCompactionEventsOptions): UseCompactionEventsReturn {
53
+ const [isCompacting, setIsCompacting] = useState(false);
54
+ const [lastCompaction, setLastCompaction] = useState<CompactionEvent | null>(null);
55
+ const [compactionCount, setCompactionCount] = useState(0);
56
+ const [showBanner, setShowBanner] = useState(false);
57
+ const bannerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
58
+ const onCompleteRef = useRef(onCompactionComplete);
59
+ onCompleteRef.current = onCompactionComplete;
60
+
61
+ useEffect(() => {
62
+ if (!sessionKey) return;
63
+
64
+ // Subscribe to all events and filter for compaction events.
65
+ // Compaction events come through as agent events with stream: "compaction"
66
+ const unsubscribe = subscribe("*", (evt: SSEEvent) => {
67
+ // Check for compaction event payload
68
+ const payload = evt.payload as Record<string, unknown> | undefined;
69
+ if (!payload) return;
70
+
71
+ // Compaction events can arrive in several shapes depending on Gateway version.
72
+ // Look for stream === "compaction" in the payload
73
+ const stream = payload.stream as string | undefined;
74
+ if (stream !== "compaction") return;
75
+
76
+ const data = (payload.data ?? payload) as {
77
+ phase?: string;
78
+ willRetry?: boolean;
79
+ };
80
+
81
+ const phase = data.phase as "start" | "end" | undefined;
82
+ if (!phase) return;
83
+
84
+ const compEvent: CompactionEvent = {
85
+ phase,
86
+ willRetry: data.willRetry,
87
+ timestamp: Date.now(),
88
+ };
89
+
90
+ setLastCompaction(compEvent);
91
+
92
+ if (phase === "start") {
93
+ setIsCompacting(true);
94
+ } else if (phase === "end") {
95
+ setIsCompacting(false);
96
+ setCompactionCount((c) => c + 1);
97
+
98
+ // Show banner
99
+ setShowBanner(true);
100
+ if (bannerTimer.current) clearTimeout(bannerTimer.current);
101
+ bannerTimer.current = setTimeout(() => setShowBanner(false), 8000);
102
+
103
+ // Notify parent (e.g. to refresh session stats)
104
+ onCompleteRef.current?.();
105
+ }
106
+ });
107
+
108
+ return () => {
109
+ unsubscribe();
110
+ if (bannerTimer.current) {
111
+ clearTimeout(bannerTimer.current);
112
+ bannerTimer.current = null;
113
+ }
114
+ };
115
+ }, [sessionKey]);
116
+
117
+ const dismissBanner = useCallback(() => {
118
+ setShowBanner(false);
119
+ if (bannerTimer.current) {
120
+ clearTimeout(bannerTimer.current);
121
+ bannerTimer.current = null;
122
+ }
123
+ }, []);
124
+
125
+ return {
126
+ isCompacting,
127
+ lastCompaction,
128
+ compactionCount,
129
+ showBanner,
130
+ dismissBanner,
131
+ };
132
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import useSWR from "swr";
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ interface ContextBoundaryData {
11
+ boundaryMessageId: string | null;
12
+ fresh: boolean;
13
+ }
14
+
15
+ interface UseContextBoundaryOptions {
16
+ /** Gateway session key. Null disables fetching. */
17
+ sessionKey: string | null;
18
+ /** Channel ID for matching preview messages to local DB. */
19
+ channelId: string;
20
+ }
21
+
22
+ interface UseContextBoundaryReturn {
23
+ /** ID of the oldest message still in agent context. Null = no compaction yet or unknown. */
24
+ boundaryMessageId: string | null;
25
+ /** Whether data is loading. */
26
+ isLoading: boolean;
27
+ /** Re-fetch the boundary (call after compaction events). */
28
+ refresh: () => void;
29
+ }
30
+
31
+ // ============================================================================
32
+ // Fetcher
33
+ // ============================================================================
34
+
35
+ const fetcher = async (url: string): Promise<ContextBoundaryData | null> => {
36
+ const res = await fetch(url);
37
+ if (res.status === 204) return null;
38
+ if (!res.ok) return null;
39
+ return res.json();
40
+ };
41
+
42
+ // ============================================================================
43
+ // Hook
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Fetches and caches the compaction context boundary for a session.
48
+ *
49
+ * Returns the ID of the oldest message the agent can still "see".
50
+ * Messages before this boundary have been compacted (summarized).
51
+ *
52
+ * Polls infrequently (every 60s) since compaction events trigger explicit refreshes.
53
+ */
54
+ export function useContextBoundary({
55
+ sessionKey,
56
+ channelId,
57
+ }: UseContextBoundaryOptions): UseContextBoundaryReturn {
58
+ const swrKey = sessionKey
59
+ ? `/api/openclaw/session/context?sessionKey=${encodeURIComponent(sessionKey)}&channelId=${encodeURIComponent(channelId)}`
60
+ : null;
61
+
62
+ const { data, isLoading, mutate } = useSWR<ContextBoundaryData | null>(
63
+ swrKey,
64
+ fetcher,
65
+ {
66
+ refreshInterval: 60000, // Refresh every 60s as background check
67
+ dedupingInterval: 10000,
68
+ revalidateOnFocus: false,
69
+ errorRetryCount: 1,
70
+ }
71
+ );
72
+
73
+ const refresh = useCallback(() => {
74
+ mutate();
75
+ }, [mutate]);
76
+
77
+ return {
78
+ boundaryMessageId: data?.boundaryMessageId ?? null,
79
+ isLoading,
80
+ refresh,
81
+ };
82
+ }
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef, useCallback } from "react";
3
+ import { useEffect, useCallback } from "react";
4
4
  import useSWR from "swr";
5
+ import { subscribe, onError, type SSEEvent } from "@/lib/sse-singleton";
5
6
 
6
7
  // ============================================================================
7
8
  // Types
@@ -27,24 +28,24 @@ export interface OpenClawStatus {
27
28
  };
28
29
  }
29
30
 
30
- interface GatewaySSEEvent {
31
- event: string;
32
- payload?: unknown;
33
- seq?: number;
34
- }
35
-
36
31
  // ============================================================================
37
32
  // Fetchers
38
33
  // ============================================================================
39
34
 
40
35
  const statusFetcher = async (url: string): Promise<OpenClawStatus> => {
41
36
  const res = await fetch(url, { method: "POST" });
37
+ if (!res.ok) {
38
+ console.warn(`[useOpenClaw] Status fetch returned ${res.status}`);
39
+ }
42
40
  return res.json();
43
41
  };
44
42
 
45
43
  const agentsFetcher = async (url: string): Promise<OpenClawAgent[]> => {
46
44
  const res = await fetch(url);
47
- if (!res.ok) return [];
45
+ if (!res.ok) {
46
+ console.warn(`[useOpenClaw] Agents fetch failed: ${res.status}`);
47
+ return [];
48
+ }
48
49
  const data = await res.json();
49
50
  return data.agents || [];
50
51
  };
@@ -61,7 +62,6 @@ const agentsFetcher = async (url: string): Promise<OpenClawAgent[]> => {
61
62
  * SSE subscription pushes real-time updates that trigger SWR cache invalidation.
62
63
  */
63
64
  export function useOpenClaw() {
64
- const eventSourceRef = useRef<EventSource | null>(null);
65
65
 
66
66
  // ---------------------------------------------------------------------------
67
67
  // SWR: Connection status
@@ -114,66 +114,53 @@ export function useOpenClaw() {
114
114
  const refreshAgents = useCallback(() => mutateAgents(), [mutateAgents]);
115
115
 
116
116
  // ---------------------------------------------------------------------------
117
- // SSE subscription for real-time events
117
+ // SSE subscription for real-time events via shared singleton
118
118
  // ---------------------------------------------------------------------------
119
119
  useEffect(() => {
120
- const es = new EventSource("/api/openclaw/events");
121
- eventSourceRef.current = es;
122
-
123
- es.onmessage = (e) => {
124
- try {
125
- const evt: GatewaySSEEvent = JSON.parse(e.data);
126
-
127
- // Handle Castle state changes — update status via SWR
128
- if (evt.event === "castle.state") {
129
- const payload = evt.payload as {
130
- state: string;
131
- isConnected: boolean;
132
- server?: Record<string, unknown>;
133
- };
134
-
135
- // Optimistically update the SWR cache with the SSE data
136
- mutateStatus(
137
- (prev) => ({
138
- ok: payload.isConnected,
139
- configured: prev?.configured ?? true,
140
- state: payload.state,
141
- server: payload.server as OpenClawStatus["server"],
142
- }),
143
- { revalidate: false }
144
- );
145
-
146
- // Re-fetch agents when connection state changes to connected
147
- if (payload.isConnected) {
148
- mutateAgents();
149
- }
150
- }
151
-
152
- // Handle agent-related events — re-fetch agent list
153
- if (evt.event?.startsWith("agent.")) {
154
- mutateAgents();
155
- }
156
-
157
- // Handle avatar update events
158
- if (evt.event === "agentAvatarUpdated") {
159
- mutateAgents();
160
- }
161
- } catch {
162
- // Ignore parse errors
120
+ const handleState = (evt: SSEEvent) => {
121
+ const payload = evt.payload as {
122
+ state: string;
123
+ isConnected: boolean;
124
+ server?: Record<string, unknown>;
125
+ };
126
+
127
+ mutateStatus(
128
+ (prev) => ({
129
+ ok: payload.isConnected,
130
+ configured: prev?.configured ?? true,
131
+ state: payload.state,
132
+ server: payload.server as OpenClawStatus["server"],
133
+ }),
134
+ { revalidate: false }
135
+ );
136
+
137
+ if (payload.isConnected) {
138
+ mutateAgents();
163
139
  }
164
140
  };
165
141
 
166
- es.onerror = () => {
167
- // EventSource auto-reconnects, but update state
142
+ const handleAgent = (_evt: SSEEvent) => {
143
+ mutateAgents();
144
+ };
145
+
146
+ const handleError = () => {
147
+ console.warn("[useOpenClaw] SSE error — marking as disconnected");
168
148
  mutateStatus(
169
149
  (prev) => prev ? { ...prev, ok: false, state: "disconnected" } : prev,
170
150
  { revalidate: false }
171
151
  );
172
152
  };
173
153
 
154
+ const unsub1 = subscribe("castle.state", handleState);
155
+ const unsub2 = subscribe("agent.*", handleAgent);
156
+ const unsub3 = subscribe("agentAvatarUpdated", handleAgent);
157
+ const unsub4 = onError(handleError);
158
+
174
159
  return () => {
175
- es.close();
176
- eventSourceRef.current = null;
160
+ unsub1();
161
+ unsub2();
162
+ unsub3();
163
+ unsub4();
177
164
  };
178
165
  }, [mutateStatus, mutateAgents]);
179
166
 
@@ -81,6 +81,7 @@ export function useSearch() {
81
81
  }
82
82
  } catch (err) {
83
83
  if ((err as Error).name !== "AbortError") {
84
+ console.error("[useSearch] Search failed:", (err as Error).message);
84
85
  setResults([]);
85
86
  }
86
87
  } finally {
@@ -7,7 +7,10 @@ import type { SessionStatus } from "@/lib/types/chat";
7
7
  const fetcher = async (url: string): Promise<SessionStatus | null> => {
8
8
  const res = await fetch(url);
9
9
  if (res.status === 204) return null;
10
- if (!res.ok) throw new Error("Failed to fetch session stats");
10
+ if (!res.ok) {
11
+ console.error(`[useSessionStats] Fetch failed: ${res.status} ${url}`);
12
+ throw new Error("Failed to fetch session stats");
13
+ }
11
14
  return res.json();
12
15
  };
13
16
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shared SSE singleton for Castle.
3
+ *
4
+ * Creates a SINGLE EventSource connection to /api/openclaw/events that is
5
+ * shared across all consumers (use-chat, use-openclaw, etc.).
6
+ *
7
+ * Ref-counted: the first subscriber opens the connection, the last
8
+ * unsubscribe closes it. Events are dispatched to handlers by event type.
9
+ *
10
+ * Deduplication: tracks the last seen `seq` number and drops duplicates
11
+ * as a safety net against multiple connections or reconnect replays.
12
+ */
13
+
14
+ export interface SSEEvent {
15
+ event: string;
16
+ payload?: unknown;
17
+ seq?: number;
18
+ }
19
+
20
+ type EventHandler = (evt: SSEEvent) => void;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Module-level singleton state
24
+ // ---------------------------------------------------------------------------
25
+
26
+ let es: EventSource | null = null;
27
+ let refCount = 0;
28
+ let lastSeq = -1;
29
+ let lastEventTimestamp = Date.now();
30
+
31
+ /** Handlers keyed by event pattern. Exact match or "*" for all events. */
32
+ const handlers = new Map<string, Set<EventHandler>>();
33
+
34
+ /** Error handlers notified on connection loss */
35
+ const errorHandlers = new Set<() => void>();
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Internal helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function matchesPattern(eventType: string, pattern: string): boolean {
42
+ if (pattern === "*") return true;
43
+ if (pattern.endsWith(".*")) {
44
+ return eventType.startsWith(pattern.slice(0, -1));
45
+ }
46
+ return eventType === pattern;
47
+ }
48
+
49
+ function dispatch(evt: SSEEvent) {
50
+ // Update last-event timestamp for heartbeat monitoring
51
+ lastEventTimestamp = Date.now();
52
+
53
+ // Dedup by seq — drop events we've already seen
54
+ if (typeof evt.seq === "number") {
55
+ if (evt.seq <= lastSeq) {
56
+ console.debug("[SSE Client] Dropped duplicate event seq:", evt.seq, "event:", evt.event);
57
+ return;
58
+ }
59
+ lastSeq = evt.seq;
60
+ }
61
+
62
+ const eventType = evt.event;
63
+ for (const [pattern, handlerSet] of handlers) {
64
+ if (matchesPattern(eventType, pattern)) {
65
+ for (const handler of handlerSet) {
66
+ try {
67
+ handler(evt);
68
+ } catch (err) {
69
+ console.error("[SSE] Handler error:", err);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ function openConnection() {
77
+ if (es) return;
78
+
79
+ console.log("[SSE Client] Opening connection (subscribers:", refCount + ")");
80
+ es = new EventSource("/api/openclaw/events");
81
+ lastEventTimestamp = Date.now();
82
+
83
+ es.onopen = () => {
84
+ console.log("[SSE Client] Connected");
85
+ };
86
+
87
+ es.onmessage = (e) => {
88
+ try {
89
+ const evt: SSEEvent = JSON.parse(e.data);
90
+ dispatch(evt);
91
+ } catch (err) {
92
+ console.warn("[SSE Client] Failed to parse event:", (err as Error).message, "data:", e.data?.slice(0, 100));
93
+ }
94
+ };
95
+
96
+ es.onerror = (e) => {
97
+ console.warn("[SSE Client] Connection error (readyState:", es?.readyState + ")");
98
+ // EventSource auto-reconnects. Notify error handlers so they can
99
+ // update UI state (e.g. show "disconnected").
100
+ for (const handler of errorHandlers) {
101
+ try {
102
+ handler();
103
+ } catch {
104
+ // ignore
105
+ }
106
+ }
107
+ };
108
+ }
109
+
110
+ function closeConnection() {
111
+ if (es) {
112
+ console.log("[SSE Client] Closing connection");
113
+ es.close();
114
+ es = null;
115
+ }
116
+ lastSeq = -1;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Public API
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Subscribe to SSE events matching a pattern.
125
+ *
126
+ * Patterns:
127
+ * - `"chat"` — exact match for chat events
128
+ * - `"castle.state"` — exact match for state events
129
+ * - `"agent.*"` — wildcard: matches agent.created, agent.updated, etc.
130
+ * - `"*"` — all events
131
+ *
132
+ * Returns an unsubscribe function. Call it in your useEffect cleanup.
133
+ */
134
+ export function subscribe(pattern: string, handler: EventHandler): () => void {
135
+ if (!handlers.has(pattern)) {
136
+ handlers.set(pattern, new Set());
137
+ }
138
+ handlers.get(pattern)!.add(handler);
139
+
140
+ refCount++;
141
+ if (refCount === 1) {
142
+ openConnection();
143
+ }
144
+
145
+ return () => {
146
+ const set = handlers.get(pattern);
147
+ if (set) {
148
+ set.delete(handler);
149
+ if (set.size === 0) handlers.delete(pattern);
150
+ }
151
+
152
+ refCount--;
153
+ if (refCount <= 0) {
154
+ refCount = 0;
155
+ closeConnection();
156
+ }
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Subscribe to connection error events.
162
+ * Returns an unsubscribe function.
163
+ */
164
+ export function onError(handler: () => void): () => void {
165
+ errorHandlers.add(handler);
166
+ return () => {
167
+ errorHandlers.delete(handler);
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Get the timestamp of the last received SSE event.
173
+ * Used for heartbeat-based timeout detection.
174
+ */
175
+ export function getLastEventTimestamp(): number {
176
+ return lastEventTimestamp;
177
+ }
178
+
179
+ /**
180
+ * Check if the SSE connection is currently open.
181
+ */
182
+ export function isConnected(): boolean {
183
+ return es !== null && es.readyState !== EventSource.CLOSED;
184
+ }
@@ -122,26 +122,42 @@ export interface QueuedMessage {
122
122
  }
123
123
 
124
124
  // ============================================================================
125
- // Session Status (from Gateway session.status RPC)
125
+ // Session Status (read from OpenClaw session store on filesystem)
126
126
  // ============================================================================
127
127
 
128
128
  export interface SessionStatus {
129
129
  sessionKey: string;
130
+ sessionId: string;
130
131
  agentId: string;
131
132
  model: string;
133
+ modelProvider: string;
132
134
  tokens: {
133
135
  input: number;
134
136
  output: number;
137
+ total: number;
135
138
  };
136
139
  context: {
137
- used: number;
138
- limit: number;
139
- percentage: number;
140
+ used: number; // totalTokens (current context window usage)
141
+ limit: number; // effective operating limit (from config, default 200k)
142
+ modelMax: number; // model's theoretical max (e.g. 1M for Sonnet 4.5)
143
+ percentage: number; // used / limit * 100
140
144
  };
141
145
  compactions: number;
142
- runtime: string;
143
- thinking: string;
146
+ thinkingLevel: string | null;
144
147
  updatedAt: number;
148
+ /** System prompt breakdown — only present when the session has run at least once */
149
+ systemPrompt?: {
150
+ totalChars: number;
151
+ projectContextChars: number;
152
+ nonProjectContextChars: number;
153
+ skills: { promptChars: number; count: number };
154
+ tools: { listChars: number; schemaChars: number; count: number };
155
+ workspaceFiles: Array<{
156
+ name: string;
157
+ injectedChars: number;
158
+ truncated: boolean;
159
+ }>;
160
+ };
145
161
  }
146
162
 
147
163
  // ============================================================================