@castlekit/castle 0.3.0 → 0.3.2

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 +18 -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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Brian Laughlan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/install.sh CHANGED
@@ -569,8 +569,27 @@ install_castle() {
569
569
  echo -e "${WARN}→${NC} Installing Castle (${INFO}${CASTLE_VERSION}${NC})..."
570
570
  fi
571
571
 
572
- if ! npm --loglevel "$NPM_LOGLEVEL" ${NPM_SILENT_FLAG:+$NPM_SILENT_FLAG} --no-fund --no-audit install -g "$install_spec"; then
572
+ # Run npm install in background with a spinner so the user knows it's working
573
+ local npm_log
574
+ npm_log="$(mktempfile)"
575
+ npm --loglevel "$NPM_LOGLEVEL" ${NPM_SILENT_FLAG:+$NPM_SILENT_FLAG} --no-fund --no-audit install -g "$install_spec" > "$npm_log" 2>&1 &
576
+ local npm_pid=$!
577
+
578
+ local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
579
+ local i=0
580
+ while kill -0 "$npm_pid" 2>/dev/null; do
581
+ local c="${spin_chars:i%${#spin_chars}:1}"
582
+ printf "\r ${ACCENT}%s${NC} Installing..." "$c"
583
+ ((i++)) || true
584
+ sleep 0.1
585
+ done
586
+ printf "\r \r"
587
+
588
+ wait "$npm_pid"
589
+ local npm_exit=$?
590
+ if [[ "$npm_exit" -ne 0 ]]; then
573
591
  echo -e "${ERROR}npm install failed${NC}"
592
+ cat "$npm_log"
574
593
  echo -e "Try: ${INFO}npm install -g --force ${install_spec}${NC}"
575
594
  exit 1
576
595
  fi
package/package.json CHANGED
@@ -1,18 +1,34 @@
1
1
  {
2
2
  "name": "@castlekit/castle",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "The multi-agent workspace",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "castle": "./bin/castle.js"
8
8
  },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "index.js",
13
+ "install.sh",
14
+ "next.config.ts",
15
+ "postcss.config.mjs",
16
+ "tsconfig.json",
17
+ "drizzle.config.ts",
18
+ "LICENSE",
19
+ "README.md",
20
+ "!src/**/__tests__/**",
21
+ "!src/**/*.test.*"
22
+ ],
9
23
  "scripts": {
10
24
  "dev": "next dev -p 3333",
11
25
  "build": "next build",
12
26
  "start": "next start -p 3333",
13
27
  "lint": "next lint",
14
28
  "test": "vitest run",
15
- "test:watch": "vitest"
29
+ "test:watch": "vitest",
30
+ "stress": "vitest run --config vitest.stress.config.ts",
31
+ "ci": "npm audit --omit=dev --audit-level=high && npm test && npm run build"
16
32
  },
17
33
  "repository": {
18
34
  "type": "git",
@@ -105,10 +105,14 @@ export async function GET() {
105
105
  }
106
106
 
107
107
  try {
108
+ const _start = Date.now();
108
109
  // Fetch agents and config in parallel
109
110
  const [agentsResult, configResult] = await Promise.all([
110
111
  gw.request<AgentsListPayload>("agents.list", {}),
111
- gw.request<ConfigGetPayload>("config.get", {}).catch(() => null),
112
+ gw.request<ConfigGetPayload>("config.get", {}).catch((err) => {
113
+ console.warn("[Agents API] config.get failed (non-fatal):", (err as Error).message);
114
+ return null;
115
+ }),
112
116
  ]);
113
117
 
114
118
  // Build workspace lookup from config
@@ -130,11 +134,13 @@ export async function GET() {
130
134
  return { id: agent.id, name, description, avatar, emoji };
131
135
  });
132
136
 
137
+ console.log(`[Agents API] GET list OK — ${agents.length} agents (${Date.now() - _start}ms)`);
133
138
  return NextResponse.json({
134
139
  agents,
135
140
  defaultId: agentsResult?.defaultId,
136
141
  });
137
142
  } catch (err) {
143
+ console.error("[Agents API] GET list FAILED:", err instanceof Error ? err.message : "Unknown error");
138
144
  return NextResponse.json(
139
145
  { error: err instanceof Error ? err.message : "Failed to list agents", agents: [] },
140
146
  { status: 500 }
@@ -41,9 +41,10 @@ export async function GET(request: NextRequest) {
41
41
 
42
42
  const archived = searchParams.get("archived") === "1";
43
43
  const all = getChannels(archived);
44
+ console.log(`[Channels API] GET list OK — ${all.length} channels (archived=${archived})`);
44
45
  return NextResponse.json({ channels: all });
45
46
  } catch (err) {
46
- console.error("[Chat Channels] List failed:", (err as Error).message);
47
+ console.error("[Channels API] GET list FAILED:", (err as Error).message);
47
48
  return NextResponse.json(
48
49
  { error: sanitizeForApi((err as Error).message) },
49
50
  { status: 500 }
@@ -129,9 +130,10 @@ export async function POST(request: NextRequest) {
129
130
  if (!deleted) {
130
131
  return NextResponse.json({ error: "Channel not found" }, { status: 404 });
131
132
  }
133
+ console.log(`[Channels API] POST delete OK — id=${body.id}`);
132
134
  return NextResponse.json({ ok: true });
133
135
  } catch (err) {
134
- console.error("[Chat Channels] Delete failed:", (err as Error).message);
136
+ console.error(`[Channels API] POST delete FAILED — id=${body.id}:`, (err as Error).message);
135
137
  return NextResponse.json(
136
138
  { error: sanitizeForApi((err as Error).message) },
137
139
  { status: 500 }
@@ -203,9 +205,10 @@ export async function POST(request: NextRequest) {
203
205
 
204
206
  try {
205
207
  const channel = createChannel(cleanName, body.defaultAgentId, body.agents);
208
+ console.log(`[Channels API] POST create OK — id=${channel.id} name="${cleanName}"`);
206
209
  return NextResponse.json({ channel }, { status: 201 });
207
210
  } catch (err) {
208
- console.error("[Chat Channels] Create failed:", (err as Error).message);
211
+ console.error(`[Channels API] POST create FAILED — name="${cleanName}":`, (err as Error).message);
209
212
  return NextResponse.json(
210
213
  { error: sanitizeForApi((err as Error).message) },
211
214
  { status: 500 }
@@ -22,12 +22,16 @@ const MAX_MESSAGE_LENGTH = 32768; // 32KB
22
22
  // ============================================================================
23
23
 
24
24
  export async function POST(request: NextRequest) {
25
+ const _start = Date.now();
25
26
  const csrf = checkCsrf(request);
26
27
  if (csrf) return csrf;
27
28
 
28
29
  // Rate limit: 30 messages per minute
29
30
  const rl = checkRateLimit(rateLimitKey(request, "chat:send"), 30);
30
- if (rl) return rl;
31
+ if (rl) {
32
+ console.warn("[Chat API] Rate limited on chat:send");
33
+ return rl;
34
+ }
31
35
 
32
36
  let body: ChatSendRequest;
33
37
  try {
@@ -100,6 +104,7 @@ export async function POST(request: NextRequest) {
100
104
  sessionKey,
101
105
  });
102
106
 
107
+ console.log(`[Chat API] POST send OK — runId=${runId} channel=${body.channelId} (${Date.now() - _start}ms)`);
103
108
  return NextResponse.json({
104
109
  runId,
105
110
  messageId: userMsg.id,
@@ -110,9 +115,9 @@ export async function POST(request: NextRequest) {
110
115
  try {
111
116
  deleteMessage(userMsg.id);
112
117
  } catch (delErr) {
113
- console.error("[Chat API] Cleanup failed:", (delErr as Error).message);
118
+ console.error("[Chat API] Cleanup of optimistic message failed:", (delErr as Error).message);
114
119
  }
115
- console.error("[Chat API] Send failed:", (err as Error).message);
120
+ console.error(`[Chat API] POST send FAILED — channel=${body.channelId} (${Date.now() - _start}ms):`, (err as Error).message);
116
121
  return NextResponse.json(
117
122
  { error: sanitizeForApi((err as Error).message) },
118
123
  { status: 502 }
@@ -125,6 +130,7 @@ export async function POST(request: NextRequest) {
125
130
  // ============================================================================
126
131
 
127
132
  export async function PUT(request: NextRequest) {
133
+ const _start = Date.now();
128
134
  const csrf = checkCsrf(request);
129
135
  if (csrf) return csrf;
130
136
 
@@ -178,9 +184,10 @@ export async function PUT(request: NextRequest) {
178
184
  outputTokens: body.outputTokens,
179
185
  });
180
186
 
187
+ console.log(`[Chat API] PUT complete OK — runId=${body.runId} new msg=${agentMsg.id} (${Date.now() - _start}ms)`);
181
188
  return NextResponse.json({ messageId: agentMsg.id, updated: false });
182
189
  } catch (err) {
183
- console.error("[Chat API] Complete failed:", (err as Error).message);
190
+ console.error(`[Chat API] PUT complete FAILED — runId=${body.runId} (${Date.now() - _start}ms):`, (err as Error).message);
184
191
  return NextResponse.json(
185
192
  { error: sanitizeForApi((err as Error).message) },
186
193
  { status: 500 }
@@ -196,12 +203,14 @@ export async function DELETE(request: NextRequest) {
196
203
  const csrf = checkCsrf(request);
197
204
  if (csrf) return csrf;
198
205
 
206
+ console.log("[Chat API] DELETE abort requested");
199
207
  try {
200
208
  const gateway = ensureGateway();
201
209
  await gateway.request("chat.abort", {});
210
+ console.log("[Chat API] DELETE abort OK");
202
211
  return NextResponse.json({ ok: true });
203
212
  } catch (err) {
204
- console.error("[Chat API] Abort failed:", (err as Error).message);
213
+ console.error("[Chat API] DELETE abort FAILED:", (err as Error).message);
205
214
  return NextResponse.json(
206
215
  { error: sanitizeForApi((err as Error).message) },
207
216
  { status: 502 }
@@ -217,6 +226,7 @@ export async function DELETE(request: NextRequest) {
217
226
  // ============================================================================
218
227
 
219
228
  export async function GET(request: NextRequest) {
229
+ const _start = Date.now();
220
230
  const { searchParams } = new URL(request.url);
221
231
  const channelId = searchParams.get("channelId");
222
232
  const limit = Math.min(parseInt(searchParams.get("limit") || "50", 10), 200);
@@ -258,12 +268,13 @@ export async function GET(request: NextRequest) {
258
268
 
259
269
  // Default / backward pagination
260
270
  const msgs = getMessagesByChannel(channelId, limit, before);
271
+ console.log(`[Chat API] GET history OK — channel=${channelId} msgs=${msgs.length} (${Date.now() - _start}ms)`);
261
272
  return NextResponse.json({
262
273
  messages: msgs,
263
274
  hasMore: msgs.length === limit,
264
275
  });
265
276
  } catch (err) {
266
- console.error("[Chat API] History failed:", (err as Error).message);
277
+ console.error(`[Chat API] GET history FAILED — channel=${channelId} (${Date.now() - _start}ms):`, (err as Error).message);
267
278
  return NextResponse.json(
268
279
  { error: sanitizeForApi((err as Error).message) },
269
280
  { status: 500 }
@@ -93,9 +93,10 @@ export async function GET(request: NextRequest) {
93
93
  // Future: merge results from searchTasks(), searchNotes(), etc.
94
94
  // Sort by timestamp descending (already sorted by FTS query)
95
95
 
96
+ console.log(`[Search API] GET OK — query="${q.slice(0, 50)}" results=${results.length}`);
96
97
  return NextResponse.json({ results });
97
98
  } catch (err) {
98
- console.error("[Chat Search] Failed:", (err as Error).message);
99
+ console.error(`[Search API] GET FAILED — query="${q?.slice(0, 50)}":`, (err as Error).message);
99
100
  return NextResponse.json(
100
101
  { error: sanitizeForApi((err as Error).message) },
101
102
  { status: 500 }
@@ -125,8 +125,10 @@ export async function PATCH(request: NextRequest) {
125
125
  }
126
126
 
127
127
  await gw.request("config.patch", patch);
128
+ console.log("[Config API] PATCH OK");
128
129
  return NextResponse.json({ ok: true });
129
130
  } catch (err) {
131
+ console.error("[Config API] PATCH FAILED:", err instanceof Error ? err.message : "Unknown error");
130
132
  return NextResponse.json(
131
133
  { error: err instanceof Error ? err.message : "Config patch failed" },
132
134
  { status: 500 }
@@ -36,11 +36,19 @@ function redactEventPayload(evt: GatewayEvent): GatewayEvent {
36
36
  * SSE endpoint -- streams OpenClaw Gateway events to the browser in real-time.
37
37
  * Browser connects once via EventSource and receives push updates.
38
38
  */
39
- export async function GET() {
39
+ export async function GET(request: Request) {
40
40
  const gw = ensureGateway();
41
41
 
42
42
  const encoder = new TextEncoder();
43
43
  let closed = false;
44
+ const connectedAt = Date.now();
45
+ let eventCount = 0;
46
+
47
+ console.log(`[SSE] Client connected (gateway: ${gw.state})`);
48
+
49
+ // Use the request's AbortSignal as the primary cleanup mechanism.
50
+ // ReadableStream.cancel() is unreliable in some environments.
51
+ const signal = request.signal;
44
52
 
45
53
  const stream = new ReadableStream({
46
54
  start(controller) {
@@ -58,11 +66,13 @@ export async function GET() {
58
66
  // Forward gateway events (with sensitive fields redacted)
59
67
  const onGatewayEvent = (evt: GatewayEvent) => {
60
68
  if (closed) return;
69
+ eventCount++;
61
70
  try {
62
71
  const safe = redactEventPayload(evt);
63
72
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(safe)}\n\n`));
64
- } catch {
65
- // Stream may have closed
73
+ } catch (err) {
74
+ console.warn(`[SSE] Stream write failed for event ${evt.event}:`, (err as Error).message);
75
+ cleanup();
66
76
  }
67
77
  };
68
78
 
@@ -80,7 +90,7 @@ export async function GET() {
80
90
  };
81
91
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
82
92
  } catch {
83
- // Stream may have closed
93
+ cleanup();
84
94
  }
85
95
  };
86
96
 
@@ -90,23 +100,28 @@ export async function GET() {
90
100
  try {
91
101
  controller.enqueue(encoder.encode(`: heartbeat\n\n`));
92
102
  } catch {
93
- // Stream may have closed
103
+ cleanup();
94
104
  }
95
105
  }, 30000);
96
106
 
97
107
  gw.on("gatewayEvent", onGatewayEvent);
98
108
  gw.on("stateChange", onStateChange);
99
109
 
100
- // Cleanup when the client disconnects
110
+ // Cleanup: remove listeners, stop heartbeat
101
111
  const cleanup = () => {
112
+ if (closed) return; // prevent double cleanup
102
113
  closed = true;
114
+ const duration = Math.round((Date.now() - connectedAt) / 1000);
115
+ console.log(`[SSE] Client disconnected (${duration}s, ${eventCount} events forwarded)`);
103
116
  clearInterval(heartbeat);
104
117
  gw.off("gatewayEvent", onGatewayEvent);
105
118
  gw.off("stateChange", onStateChange);
106
119
  };
107
120
 
108
- // The stream's cancel is called when the client disconnects
109
- // We store cleanup for the cancel callback
121
+ // Primary cleanup: request.signal fires when client disconnects
122
+ signal.addEventListener("abort", cleanup, { once: true });
123
+
124
+ // Store for cancel callback as fallback
110
125
  (controller as unknown as { _cleanup: () => void })._cleanup = cleanup;
111
126
  },
112
127
  cancel(controller) {
@@ -47,6 +47,10 @@ export async function POST() {
47
47
  await gw.request("health", {});
48
48
  const latency = Date.now() - start;
49
49
 
50
+ if (latency > 1000) {
51
+ console.warn(`[Ping] Health check slow: ${latency}ms`);
52
+ }
53
+
50
54
  return NextResponse.json({
51
55
  ok: true,
52
56
  configured: true,
@@ -54,6 +58,7 @@ export async function POST() {
54
58
  server: gw.serverInfo,
55
59
  });
56
60
  } catch (err) {
61
+ console.error("[Ping] Health check failed:", err instanceof Error ? err.message : "Unknown error");
57
62
  return NextResponse.json({
58
63
  ok: false,
59
64
  configured: true,
@@ -0,0 +1,163 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { ensureGateway } from "@/lib/gateway-connection";
3
+ import { sanitizeForApi } from "@/lib/api-security";
4
+ import {
5
+ getCompactionBoundary,
6
+ setCompactionBoundary,
7
+ } from "@/lib/db/queries";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ interface PreviewMessage {
14
+ role: string;
15
+ content?: string;
16
+ timestamp?: number;
17
+ }
18
+
19
+ interface PreviewResponse {
20
+ messages?: PreviewMessage[];
21
+ entries?: PreviewMessage[];
22
+ }
23
+
24
+ interface ContextBoundaryResponse {
25
+ /** ID of the oldest message still in the agent's context. Null if unknown. */
26
+ boundaryMessageId: string | null;
27
+ /** Whether the boundary was freshly determined (true) or loaded from cache (false). */
28
+ fresh: boolean;
29
+ }
30
+
31
+ // ============================================================================
32
+ // GET /api/openclaw/session/context?sessionKey=X&channelId=Y
33
+ //
34
+ // Determines the compaction boundary: which messages the agent can "see".
35
+ // Calls sessions.preview on the Gateway, matches against local DB, caches result.
36
+ // ============================================================================
37
+
38
+ export async function GET(request: NextRequest) {
39
+ const { searchParams } = new URL(request.url);
40
+ const sessionKey = searchParams.get("sessionKey");
41
+ const channelId = searchParams.get("channelId");
42
+
43
+ if (!sessionKey) {
44
+ return NextResponse.json(
45
+ { error: "sessionKey is required" },
46
+ { status: 400 }
47
+ );
48
+ }
49
+
50
+ try {
51
+ // First, check cached boundary
52
+ const cached = getCompactionBoundary(sessionKey);
53
+
54
+ // Try to get fresh boundary from Gateway
55
+ const gateway = ensureGateway();
56
+ let fresh = false;
57
+ let boundaryMessageId = cached;
58
+
59
+ try {
60
+ const preview = await gateway.request<PreviewResponse>(
61
+ "sessions.preview",
62
+ {
63
+ keys: [sessionKey],
64
+ limit: 100,
65
+ maxChars: 20000,
66
+ }
67
+ );
68
+
69
+ // The preview returns messages that the agent can currently see.
70
+ // Find the oldest message in the preview to determine the boundary.
71
+ const previewMessages = preview.messages || preview.entries || [];
72
+
73
+ if (previewMessages.length > 0 && channelId) {
74
+ // Import dynamically to avoid circular deps
75
+ const { getDb } = await import("@/lib/db/index");
76
+ const { messages } = await import("@/lib/db/schema");
77
+ const { eq, asc } = await import("drizzle-orm");
78
+
79
+ const db = getDb();
80
+
81
+ // Get oldest message timestamp from preview
82
+ const oldestPreview = previewMessages[0];
83
+ const oldestTimestamp = oldestPreview?.timestamp;
84
+
85
+ if (oldestTimestamp) {
86
+ // Find the Castle message closest to this timestamp
87
+ const localMessages = db
88
+ .select({ id: messages.id, createdAt: messages.createdAt })
89
+ .from(messages)
90
+ .where(eq(messages.channelId, channelId))
91
+ .orderBy(asc(messages.createdAt))
92
+ .all();
93
+
94
+ // Find the message with timestamp closest to the oldest preview message
95
+ let closestId: string | null = null;
96
+ let closestDiff = Infinity;
97
+ for (const msg of localMessages) {
98
+ const diff = Math.abs(msg.createdAt - oldestTimestamp);
99
+ if (diff < closestDiff) {
100
+ closestDiff = diff;
101
+ closestId = msg.id;
102
+ }
103
+ }
104
+
105
+ if (closestId && closestDiff < 60000) {
106
+ // Match within 60s tolerance
107
+ boundaryMessageId = closestId;
108
+ fresh = true;
109
+
110
+ // Cache it in the DB
111
+ setCompactionBoundary(sessionKey, closestId);
112
+ }
113
+ }
114
+ }
115
+ } catch (previewErr) {
116
+ // sessions.preview might not be available — fall back to cached
117
+ console.warn(
118
+ "[Session Context] sessions.preview failed, using cached boundary:",
119
+ (previewErr as Error).message
120
+ );
121
+ }
122
+
123
+ const response: ContextBoundaryResponse = {
124
+ boundaryMessageId,
125
+ fresh,
126
+ };
127
+
128
+ return NextResponse.json(response);
129
+ } catch (err) {
130
+ const message = (err as Error).message;
131
+ console.error("[Session Context] Failed:", message);
132
+ return NextResponse.json(
133
+ { error: sanitizeForApi(message) },
134
+ { status: 502 }
135
+ );
136
+ }
137
+ }
138
+
139
+ // ============================================================================
140
+ // POST /api/openclaw/session/context — Update boundary after compaction event
141
+ // ============================================================================
142
+
143
+ export async function POST(request: NextRequest) {
144
+ try {
145
+ const body = await request.json();
146
+ const { sessionKey, boundaryMessageId } = body;
147
+
148
+ if (!sessionKey || !boundaryMessageId) {
149
+ return NextResponse.json(
150
+ { error: "sessionKey and boundaryMessageId are required" },
151
+ { status: 400 }
152
+ );
153
+ }
154
+
155
+ setCompactionBoundary(sessionKey, boundaryMessageId);
156
+ return NextResponse.json({ ok: true });
157
+ } catch (err) {
158
+ return NextResponse.json(
159
+ { error: (err as Error).message },
160
+ { status: 500 }
161
+ );
162
+ }
163
+ }