@castlekit/castle 0.1.6 → 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.
- package/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- 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/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +328 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +168 -0
- package/src/components/chat/message-list.tsx +666 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +444 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +110 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- 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 +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { existsSync, statSync, readdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getCastleDir } from "@/lib/config";
|
|
5
|
+
import { getStorageStats } from "@/lib/db/queries";
|
|
6
|
+
import { getDbPath } from "@/lib/db/index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively calculate total size of a directory.
|
|
10
|
+
*/
|
|
11
|
+
function getDirSize(dir: string): number {
|
|
12
|
+
if (!existsSync(dir)) return 0;
|
|
13
|
+
|
|
14
|
+
let total = 0;
|
|
15
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const fullPath = join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
total += getDirSize(fullPath);
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
total += statSync(fullPath).size;
|
|
23
|
+
} catch {
|
|
24
|
+
// Skip files we can't stat
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// GET /api/openclaw/chat/storage — Storage stats
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export async function GET() {
|
|
36
|
+
try {
|
|
37
|
+
const stats = getStorageStats();
|
|
38
|
+
|
|
39
|
+
// Get actual DB file size
|
|
40
|
+
const dbPath = getDbPath();
|
|
41
|
+
let dbSizeBytes = 0;
|
|
42
|
+
if (existsSync(dbPath)) {
|
|
43
|
+
dbSizeBytes = statSync(dbPath).size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get actual attachments directory size
|
|
47
|
+
const attachmentsDir = join(getCastleDir(), "data", "attachments");
|
|
48
|
+
const attachmentsDirSize = getDirSize(attachmentsDir);
|
|
49
|
+
|
|
50
|
+
// Define warning thresholds
|
|
51
|
+
const WARN_DB_SIZE = 500 * 1024 * 1024; // 500MB
|
|
52
|
+
const WARN_ATTACHMENTS = 2 * 1024 * 1024 * 1024; // 2GB
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
|
|
55
|
+
if (dbSizeBytes > WARN_DB_SIZE) {
|
|
56
|
+
warnings.push(`Database is ${(dbSizeBytes / 1024 / 1024).toFixed(0)}MB — consider archiving old messages`);
|
|
57
|
+
}
|
|
58
|
+
if (attachmentsDirSize > WARN_ATTACHMENTS) {
|
|
59
|
+
warnings.push(`Attachments using ${(attachmentsDirSize / 1024 / 1024 / 1024).toFixed(1)}GB of disk space`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return NextResponse.json({
|
|
63
|
+
...stats,
|
|
64
|
+
dbSizeBytes,
|
|
65
|
+
attachmentsDirBytes: attachmentsDirSize,
|
|
66
|
+
warnings,
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("[Storage] Stats failed:", (err as Error).message);
|
|
70
|
+
return NextResponse.json(
|
|
71
|
+
{ error: "Failed to get storage stats" },
|
|
72
|
+
{ status: 500 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -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) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
4
|
import { getOpenClawDir } from "@/lib/config";
|
|
5
5
|
import { sanitizeForApi } from "@/lib/api-security";
|
|
6
6
|
|
|
@@ -14,7 +14,17 @@ export async function GET(request: NextRequest) {
|
|
|
14
14
|
const { searchParams } = new URL(request.url);
|
|
15
15
|
const rawLines = parseInt(searchParams.get("lines") || "100", 10);
|
|
16
16
|
const lines = Math.min(Math.max(1, Number.isFinite(rawLines) ? rawLines : 100), 10000);
|
|
17
|
-
const
|
|
17
|
+
const rawFile = searchParams.get("file")?.trim() || "gateway";
|
|
18
|
+
|
|
19
|
+
// Sanitize file parameter: only allow alphanumeric, hyphens, underscores, dots
|
|
20
|
+
// Reject anything with path separators or traversal patterns
|
|
21
|
+
const file = rawFile.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
22
|
+
if (!file || file.includes("..") || file !== rawFile) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Invalid log file name" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
const logsDir = join(getOpenClawDir(), "logs");
|
|
20
30
|
|
|
@@ -27,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|
|
27
37
|
? readdirSync(logsDir).filter((f) => f.endsWith(".log"))
|
|
28
38
|
: [];
|
|
29
39
|
|
|
30
|
-
// Find matching log file
|
|
40
|
+
// Find matching log file — must match prefix exactly within the logs directory
|
|
31
41
|
const logFile = availableFiles.find((f) => f.startsWith(file));
|
|
32
42
|
if (!logFile) {
|
|
33
43
|
return NextResponse.json({
|
|
@@ -39,6 +49,10 @@ export async function GET(request: NextRequest) {
|
|
|
39
49
|
|
|
40
50
|
try {
|
|
41
51
|
const logPath = join(logsDir, logFile);
|
|
52
|
+
// Final safety check: resolved path must be inside the logs directory
|
|
53
|
+
if (!resolve(logPath).startsWith(resolve(logsDir))) {
|
|
54
|
+
return NextResponse.json({ error: "Invalid log file path" }, { status: 400 });
|
|
55
|
+
}
|
|
42
56
|
const content = readFileSync(logPath, "utf-8");
|
|
43
57
|
const allLines = content.split("\n").filter(Boolean);
|
|
44
58
|
|
|
@@ -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,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
-
import { checkCsrf } from "@/lib/api-security";
|
|
3
|
+
import { checkCsrf, checkRateLimit, rateLimitKey } from "@/lib/api-security";
|
|
4
4
|
|
|
5
5
|
export const dynamic = "force-dynamic";
|
|
6
6
|
|
|
@@ -12,6 +12,11 @@ export const dynamic = "force-dynamic";
|
|
|
12
12
|
export async function POST(request: NextRequest) {
|
|
13
13
|
const csrf = checkCsrf(request);
|
|
14
14
|
if (csrf) return csrf;
|
|
15
|
+
|
|
16
|
+
// Rate limit: 5 restarts per minute
|
|
17
|
+
const rl = checkRateLimit(rateLimitKey(request, "restart"), 5);
|
|
18
|
+
if (rl) return rl;
|
|
19
|
+
|
|
15
20
|
try {
|
|
16
21
|
// Try openclaw CLI restart first
|
|
17
22
|
try {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getOpenClawDir } from "@/lib/config";
|
|
5
|
+
import { sanitizeForApi } from "@/lib/api-security";
|
|
6
|
+
import type { SessionStatus } from "@/lib/types/chat";
|
|
7
|
+
import JSON5 from "json5";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// GET /api/openclaw/session/status?sessionKey=X — Real session stats
|
|
11
|
+
//
|
|
12
|
+
// Reads directly from the OpenClaw session store on the filesystem.
|
|
13
|
+
// This is the source of truth — the same data the Gateway uses.
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
export const dynamic = "force-dynamic";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OpenClaw's default context window when nothing is configured.
|
|
20
|
+
* Matches DEFAULT_CONTEXT_TOKENS in the Gateway source (2e5 = 200,000).
|
|
21
|
+
*/
|
|
22
|
+
const OPENCLAW_DEFAULT_CONTEXT_TOKENS = 200_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a session key like "agent:main:castle:2d16fadb-..." to extract the agent ID.
|
|
26
|
+
* Format: agent:<agentId>:<channel>:<channelId>
|
|
27
|
+
*/
|
|
28
|
+
function parseAgentId(sessionKey: string): string | null {
|
|
29
|
+
const parts = sessionKey.split(":");
|
|
30
|
+
// agent:<agentId>:...
|
|
31
|
+
if (parts[0] === "agent" && parts.length >= 2) {
|
|
32
|
+
return parts[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load a session entry from the OpenClaw session store.
|
|
39
|
+
* Reads ~/.openclaw/agents/<agentId>/sessions/sessions.json
|
|
40
|
+
*/
|
|
41
|
+
function loadSessionEntry(
|
|
42
|
+
sessionKey: string,
|
|
43
|
+
agentId: string
|
|
44
|
+
): Record<string, unknown> | null {
|
|
45
|
+
const openclawDir = getOpenClawDir();
|
|
46
|
+
const storePath = join(openclawDir, "agents", agentId, "sessions", "sessions.json");
|
|
47
|
+
|
|
48
|
+
if (!existsSync(storePath)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(storePath, "utf-8");
|
|
54
|
+
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
|
|
55
|
+
return store[sessionKey] ?? null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the effective context window limit from OpenClaw config.
|
|
63
|
+
*
|
|
64
|
+
* Resolution order (matches Gateway logic):
|
|
65
|
+
* 1. agents.<agentId>.contextTokens (per-agent override)
|
|
66
|
+
* 2. agents.defaults.contextTokens (global default)
|
|
67
|
+
* 3. OPENCLAW_DEFAULT_CONTEXT_TOKENS (200k — Gateway hardcoded default)
|
|
68
|
+
*
|
|
69
|
+
* Note: The session entry's `contextTokens` stores the MODEL's max context
|
|
70
|
+
* (e.g. 1M for Sonnet 4.5) which is NOT the operating limit. The real limit
|
|
71
|
+
* is set in config or defaults to 200k.
|
|
72
|
+
*/
|
|
73
|
+
function resolveEffectiveContextLimit(agentId: string): number {
|
|
74
|
+
const openclawDir = getOpenClawDir();
|
|
75
|
+
const configPaths = [
|
|
76
|
+
join(openclawDir, "openclaw.json"),
|
|
77
|
+
join(openclawDir, "openclaw.json5"),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
for (const configPath of configPaths) {
|
|
81
|
+
if (!existsSync(configPath)) continue;
|
|
82
|
+
try {
|
|
83
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
84
|
+
const config = JSON5.parse(raw) as Record<string, unknown>;
|
|
85
|
+
const agents = config.agents as Record<string, unknown> | undefined;
|
|
86
|
+
if (!agents) continue;
|
|
87
|
+
|
|
88
|
+
// 1. Per-agent override: agents.<agentId>.contextTokens
|
|
89
|
+
const agentCfg = agents[agentId] as Record<string, unknown> | undefined;
|
|
90
|
+
if (agentCfg?.contextTokens && typeof agentCfg.contextTokens === "number") {
|
|
91
|
+
return agentCfg.contextTokens;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Global default: agents.defaults.contextTokens
|
|
95
|
+
const defaults = agents.defaults as Record<string, unknown> | undefined;
|
|
96
|
+
if (defaults?.contextTokens && typeof defaults.contextTokens === "number") {
|
|
97
|
+
return defaults.contextTokens;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Continue to next config path
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Fallback: OpenClaw's hardcoded default
|
|
105
|
+
return OPENCLAW_DEFAULT_CONTEXT_TOKENS;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function GET(request: NextRequest) {
|
|
109
|
+
const { searchParams } = new URL(request.url);
|
|
110
|
+
const sessionKey = searchParams.get("sessionKey");
|
|
111
|
+
|
|
112
|
+
if (!sessionKey) {
|
|
113
|
+
return NextResponse.json(
|
|
114
|
+
{ error: "sessionKey is required" },
|
|
115
|
+
{ status: 400 }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const agentId = parseAgentId(sessionKey);
|
|
121
|
+
if (!agentId) {
|
|
122
|
+
return NextResponse.json(
|
|
123
|
+
{ error: "Could not parse agentId from sessionKey" },
|
|
124
|
+
{ status: 400 }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const entry = loadSessionEntry(sessionKey, agentId);
|
|
129
|
+
if (!entry) {
|
|
130
|
+
console.log(`[Session Status] No session data for key=${sessionKey} agent=${agentId}`);
|
|
131
|
+
return new NextResponse(null, { status: 204 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Map the raw SessionEntry to our SessionStatus type
|
|
135
|
+
const inputTokens = (entry.inputTokens as number) ?? 0;
|
|
136
|
+
const outputTokens = (entry.outputTokens as number) ?? 0;
|
|
137
|
+
const totalTokens = (entry.totalTokens as number) ?? 0;
|
|
138
|
+
|
|
139
|
+
// The session entry's contextTokens is the MODEL's max (e.g. 1M for Sonnet 4.5).
|
|
140
|
+
// The real operating limit comes from config or defaults to 200k.
|
|
141
|
+
const modelMaxContext = (entry.contextTokens as number) ?? 0;
|
|
142
|
+
const effectiveLimit = resolveEffectiveContextLimit(agentId);
|
|
143
|
+
const percentage = effectiveLimit > 0
|
|
144
|
+
? Math.round((totalTokens / effectiveLimit) * 100)
|
|
145
|
+
: 0;
|
|
146
|
+
|
|
147
|
+
// Extract system prompt report if available
|
|
148
|
+
const spr = entry.systemPromptReport as Record<string, unknown> | undefined;
|
|
149
|
+
let systemPrompt: SessionStatus["systemPrompt"] = undefined;
|
|
150
|
+
|
|
151
|
+
if (spr) {
|
|
152
|
+
const sp = spr.systemPrompt as Record<string, number> | undefined;
|
|
153
|
+
const skills = spr.skills as Record<string, unknown> | undefined;
|
|
154
|
+
const tools = spr.tools as Record<string, unknown> | undefined;
|
|
155
|
+
const files = spr.injectedWorkspaceFiles as Array<Record<string, unknown>> | undefined;
|
|
156
|
+
|
|
157
|
+
systemPrompt = {
|
|
158
|
+
totalChars: sp?.chars ?? 0,
|
|
159
|
+
projectContextChars: sp?.projectContextChars ?? 0,
|
|
160
|
+
nonProjectContextChars: sp?.nonProjectContextChars ?? 0,
|
|
161
|
+
skills: {
|
|
162
|
+
promptChars: (skills?.promptChars as number) ?? 0,
|
|
163
|
+
count: Array.isArray(skills?.entries) ? (skills.entries as unknown[]).length : 0,
|
|
164
|
+
},
|
|
165
|
+
tools: {
|
|
166
|
+
listChars: (tools?.listChars as number) ?? 0,
|
|
167
|
+
schemaChars: (tools?.schemaChars as number) ?? 0,
|
|
168
|
+
count: Array.isArray(tools?.entries) ? (tools.entries as unknown[]).length : 0,
|
|
169
|
+
},
|
|
170
|
+
workspaceFiles: (files ?? []).map((f) => ({
|
|
171
|
+
name: (f.name as string) ?? "",
|
|
172
|
+
injectedChars: (f.injectedChars as number) ?? 0,
|
|
173
|
+
truncated: (f.truncated as boolean) ?? false,
|
|
174
|
+
})),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result: SessionStatus = {
|
|
179
|
+
sessionKey,
|
|
180
|
+
sessionId: (entry.sessionId as string) ?? "",
|
|
181
|
+
agentId,
|
|
182
|
+
model: (entry.model as string) ?? "unknown",
|
|
183
|
+
modelProvider: (entry.modelProvider as string) ?? "unknown",
|
|
184
|
+
tokens: {
|
|
185
|
+
input: inputTokens,
|
|
186
|
+
output: outputTokens,
|
|
187
|
+
total: totalTokens,
|
|
188
|
+
},
|
|
189
|
+
context: {
|
|
190
|
+
used: totalTokens,
|
|
191
|
+
limit: effectiveLimit,
|
|
192
|
+
modelMax: modelMaxContext,
|
|
193
|
+
percentage: Math.min(percentage, 100),
|
|
194
|
+
},
|
|
195
|
+
compactions: (entry.compactionCount as number) ?? 0,
|
|
196
|
+
thinkingLevel: (entry.thinkingLevel as string) ?? null,
|
|
197
|
+
updatedAt: (entry.updatedAt as number) ?? 0,
|
|
198
|
+
systemPrompt,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return NextResponse.json(result);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const message = (err as Error).message;
|
|
204
|
+
console.error("[Session Status] Failed:", message);
|
|
205
|
+
return NextResponse.json(
|
|
206
|
+
{ error: sanitizeForApi(message) },
|
|
207
|
+
{ status: 502 }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -52,8 +52,10 @@ export async function GET() {
|
|
|
52
52
|
// Sort by most recently modified
|
|
53
53
|
sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
|
|
54
54
|
|
|
55
|
+
console.log(`[Sessions API] GET OK — ${sessions.length} sessions`);
|
|
55
56
|
return NextResponse.json({ sessions });
|
|
56
57
|
} catch (err) {
|
|
58
|
+
console.error("[Sessions API] GET FAILED:", err instanceof Error ? err.message : "Unknown error");
|
|
57
59
|
return NextResponse.json(
|
|
58
60
|
{ error: err instanceof Error ? err.message : "Failed to read sessions" },
|
|
59
61
|
{ status: 500 }
|