@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.
- package/LICENSE +21 -0
- package/install.sh +20 -1
- package/package.json +18 -2
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/chat/channels/route.ts +6 -3
- package/src/app/api/openclaw/chat/route.ts +17 -6
- package/src/app/api/openclaw/chat/search/route.ts +2 -1
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +179 -11
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/chat/[channelId]/page.tsx +115 -35
- package/src/app/globals.css +10 -0
- package/src/app/page.tsx +10 -8
- package/src/components/chat/chat-input.tsx +23 -5
- package/src/components/chat/message-bubble.tsx +29 -13
- package/src/components/chat/message-list.tsx +238 -80
- package/src/components/chat/session-stats-panel.tsx +391 -86
- package/src/components/providers/search-provider.tsx +33 -4
- package/src/lib/db/index.ts +12 -2
- package/src/lib/db/queries.ts +199 -72
- package/src/lib/db/schema.ts +4 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-chat.ts +219 -241
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +44 -57
- package/src/lib/hooks/use-search.ts +1 -0
- package/src/lib/hooks/use-session-stats.ts +4 -1
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +22 -6
- package/src/lib/db/__tests__/queries.test.ts +0 -318
- 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
|
-
|
|
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.
|
|
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(() =>
|
|
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("[
|
|
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(
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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]
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
109
|
-
|
|
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
|
+
}
|