@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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. 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
- // 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) {
@@ -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 file = searchParams.get("file")?.trim() || "gateway";
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 }