@castlekit/castle 0.1.5 → 0.3.0

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 (68) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +25 -4
  4. package/src/app/api/avatars/[id]/route.ts +122 -25
  5. package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
  6. package/src/app/api/openclaw/agents/route.ts +77 -41
  7. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  8. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  9. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  10. package/src/app/api/openclaw/chat/route.ts +272 -0
  11. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  12. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  13. package/src/app/api/openclaw/config/route.ts +45 -4
  14. package/src/app/api/openclaw/events/route.ts +31 -2
  15. package/src/app/api/openclaw/logs/route.ts +20 -5
  16. package/src/app/api/openclaw/restart/route.ts +12 -4
  17. package/src/app/api/openclaw/session/status/route.ts +42 -0
  18. package/src/app/api/settings/avatar/route.ts +190 -0
  19. package/src/app/api/settings/route.ts +88 -0
  20. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  21. package/src/app/chat/[channelId]/page.tsx +305 -0
  22. package/src/app/chat/layout.tsx +96 -0
  23. package/src/app/chat/page.tsx +52 -0
  24. package/src/app/globals.css +89 -2
  25. package/src/app/layout.tsx +7 -1
  26. package/src/app/page.tsx +147 -28
  27. package/src/app/settings/page.tsx +300 -0
  28. package/src/cli/onboarding.ts +202 -37
  29. package/src/components/chat/agent-mention-popup.tsx +89 -0
  30. package/src/components/chat/archived-channels.tsx +190 -0
  31. package/src/components/chat/channel-list.tsx +140 -0
  32. package/src/components/chat/chat-input.tsx +310 -0
  33. package/src/components/chat/create-channel-dialog.tsx +171 -0
  34. package/src/components/chat/markdown-content.tsx +205 -0
  35. package/src/components/chat/message-bubble.tsx +152 -0
  36. package/src/components/chat/message-list.tsx +508 -0
  37. package/src/components/chat/message-queue.tsx +68 -0
  38. package/src/components/chat/session-divider.tsx +61 -0
  39. package/src/components/chat/session-stats-panel.tsx +139 -0
  40. package/src/components/chat/storage-indicator.tsx +76 -0
  41. package/src/components/layout/sidebar.tsx +126 -45
  42. package/src/components/layout/user-menu.tsx +29 -4
  43. package/src/components/providers/presence-provider.tsx +8 -0
  44. package/src/components/providers/search-provider.tsx +81 -0
  45. package/src/components/search/search-dialog.tsx +269 -0
  46. package/src/components/ui/avatar.tsx +11 -9
  47. package/src/components/ui/dialog.tsx +10 -4
  48. package/src/components/ui/tooltip.tsx +25 -8
  49. package/src/components/ui/twemoji-text.tsx +37 -0
  50. package/src/lib/api-security.ts +188 -0
  51. package/src/lib/config.ts +36 -4
  52. package/src/lib/date-utils.ts +79 -0
  53. package/src/lib/db/__tests__/queries.test.ts +318 -0
  54. package/src/lib/db/index.ts +642 -0
  55. package/src/lib/db/queries.ts +1017 -0
  56. package/src/lib/db/schema.ts +160 -0
  57. package/src/lib/device-identity.ts +303 -0
  58. package/src/lib/gateway-connection.ts +273 -36
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +775 -0
  61. package/src/lib/hooks/use-openclaw.ts +105 -70
  62. package/src/lib/hooks/use-search.ts +113 -0
  63. package/src/lib/hooks/use-session-stats.ts +57 -0
  64. package/src/lib/hooks/use-user-settings.ts +46 -0
  65. package/src/lib/types/chat.ts +186 -0
  66. package/src/lib/types/search.ts +60 -0
  67. package/src/middleware.ts +52 -0
  68. package/vitest.config.ts +13 -0
@@ -0,0 +1,160 @@
1
+ import { sqliteTable, text, integer, primaryKey, index } from "drizzle-orm/sqlite-core";
2
+
3
+ // ============================================================================
4
+ // channels
5
+ // ============================================================================
6
+
7
+ export const channels = sqliteTable("channels", {
8
+ id: text("id").primaryKey(),
9
+ name: text("name").notNull(),
10
+ defaultAgentId: text("default_agent_id").notNull(),
11
+ createdAt: integer("created_at").notNull(), // unix ms
12
+ updatedAt: integer("updated_at"), // unix ms
13
+ lastAccessedAt: integer("last_accessed_at"), // unix ms — last time user opened this channel
14
+ archivedAt: integer("archived_at"), // unix ms — null if active, set when archived
15
+ });
16
+
17
+ // ============================================================================
18
+ // settings (key-value store for user preferences)
19
+ // ============================================================================
20
+
21
+ export const settings = sqliteTable("settings", {
22
+ key: text("key").primaryKey(),
23
+ value: text("value").notNull(),
24
+ updatedAt: integer("updated_at").notNull(), // unix ms
25
+ });
26
+
27
+ // ============================================================================
28
+ // agent_statuses (live agent activity state)
29
+ // ============================================================================
30
+
31
+ export const agentStatuses = sqliteTable("agent_statuses", {
32
+ agentId: text("agent_id").primaryKey(),
33
+ status: text("status").notNull().default("idle"), // "idle" | "thinking" | "active"
34
+ updatedAt: integer("updated_at").notNull(), // unix ms
35
+ });
36
+
37
+ // ============================================================================
38
+ // channel_agents (many-to-many junction)
39
+ // ============================================================================
40
+
41
+ export const channelAgents = sqliteTable(
42
+ "channel_agents",
43
+ {
44
+ channelId: text("channel_id")
45
+ .notNull()
46
+ .references(() => channels.id, { onDelete: "cascade" }),
47
+ agentId: text("agent_id").notNull(),
48
+ },
49
+ (table) => [
50
+ primaryKey({ columns: [table.channelId, table.agentId] }),
51
+ ]
52
+ );
53
+
54
+ // ============================================================================
55
+ // sessions
56
+ // ============================================================================
57
+
58
+ export const sessions = sqliteTable(
59
+ "sessions",
60
+ {
61
+ id: text("id").primaryKey(),
62
+ channelId: text("channel_id")
63
+ .notNull()
64
+ .references(() => channels.id, { onDelete: "cascade" }),
65
+ sessionKey: text("session_key"), // Gateway session key
66
+ startedAt: integer("started_at").notNull(), // unix ms
67
+ endedAt: integer("ended_at"), // unix ms, nullable
68
+ summary: text("summary"),
69
+ totalInputTokens: integer("total_input_tokens").default(0),
70
+ totalOutputTokens: integer("total_output_tokens").default(0),
71
+ },
72
+ (table) => [
73
+ index("idx_sessions_channel").on(table.channelId, table.startedAt),
74
+ ]
75
+ );
76
+
77
+ // ============================================================================
78
+ // messages
79
+ // ============================================================================
80
+
81
+ export const messages = sqliteTable(
82
+ "messages",
83
+ {
84
+ id: text("id").primaryKey(),
85
+ channelId: text("channel_id")
86
+ .notNull()
87
+ .references(() => channels.id, { onDelete: "cascade" }),
88
+ sessionId: text("session_id").references(() => sessions.id),
89
+ senderType: text("sender_type").notNull(), // "user" | "agent"
90
+ senderId: text("sender_id").notNull(),
91
+ senderName: text("sender_name"),
92
+ content: text("content").notNull().default(""),
93
+ status: text("status").notNull().default("complete"), // "complete" | "interrupted" | "aborted"
94
+ mentionedAgentId: text("mentioned_agent_id"),
95
+ runId: text("run_id"), // Gateway run ID for streaming correlation
96
+ sessionKey: text("session_key"), // Gateway session key
97
+ inputTokens: integer("input_tokens"),
98
+ outputTokens: integer("output_tokens"),
99
+ createdAt: integer("created_at").notNull(), // unix ms
100
+ },
101
+ (table) => [
102
+ index("idx_messages_channel").on(table.channelId, table.createdAt),
103
+ index("idx_messages_session").on(table.sessionId, table.createdAt),
104
+ index("idx_messages_run_id").on(table.runId),
105
+ ]
106
+ );
107
+
108
+ // ============================================================================
109
+ // message_attachments
110
+ // ============================================================================
111
+
112
+ export const messageAttachments = sqliteTable(
113
+ "message_attachments",
114
+ {
115
+ id: text("id").primaryKey(),
116
+ messageId: text("message_id")
117
+ .notNull()
118
+ .references(() => messages.id, { onDelete: "cascade" }),
119
+ attachmentType: text("attachment_type").notNull(), // "image" | "audio"
120
+ filePath: text("file_path").notNull(),
121
+ mimeType: text("mime_type"),
122
+ fileSize: integer("file_size"),
123
+ originalName: text("original_name"),
124
+ createdAt: integer("created_at").notNull(), // unix ms
125
+ },
126
+ (table) => [
127
+ index("idx_attachments_message").on(table.messageId),
128
+ ]
129
+ );
130
+
131
+ // ============================================================================
132
+ // recent_searches
133
+ // ============================================================================
134
+
135
+ export const recentSearches = sqliteTable("recent_searches", {
136
+ id: integer("id").primaryKey({ autoIncrement: true }),
137
+ query: text("query").notNull(),
138
+ createdAt: integer("created_at").notNull(), // unix ms
139
+ });
140
+
141
+ // ============================================================================
142
+ // message_reactions
143
+ // ============================================================================
144
+
145
+ export const messageReactions = sqliteTable(
146
+ "message_reactions",
147
+ {
148
+ id: text("id").primaryKey(),
149
+ messageId: text("message_id")
150
+ .notNull()
151
+ .references(() => messages.id, { onDelete: "cascade" }),
152
+ agentId: text("agent_id"),
153
+ emoji: text("emoji").notNull(),
154
+ emojiChar: text("emoji_char").notNull(),
155
+ createdAt: integer("created_at").notNull(), // unix ms
156
+ },
157
+ (table) => [
158
+ index("idx_reactions_message").on(table.messageId),
159
+ ]
160
+ );
@@ -0,0 +1,303 @@
1
+ import { generateKeyPairSync, sign, createHash } from "crypto";
2
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from "fs";
3
+ import { join } from "path";
4
+ import { platform } from "os";
5
+ import { getCastleDir, ensureCastleDir } from "./config";
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ export interface DeviceIdentity {
12
+ deviceId: string;
13
+ publicKey: string; // PEM-encoded Ed25519 public key
14
+ privateKey: string; // PEM-encoded Ed25519 private key
15
+ createdAt: string; // ISO-8601
16
+ deviceToken?: string;
17
+ pairedAt?: string; // ISO-8601
18
+ gatewayUrl?: string;
19
+ }
20
+
21
+ export interface DeviceInfo {
22
+ deviceId: string;
23
+ publicKey: string;
24
+ fingerprint: string;
25
+ createdAt: string;
26
+ isPaired: boolean;
27
+ pairedAt?: string;
28
+ gatewayUrl?: string;
29
+ }
30
+
31
+ // ============================================================================
32
+ // Paths
33
+ // ============================================================================
34
+
35
+ function getDevicePath(): string {
36
+ return join(getCastleDir(), "device.json");
37
+ }
38
+
39
+ // ============================================================================
40
+ // Helpers
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Check if a stored key is PEM format (vs old hex format).
45
+ */
46
+ function isPem(key: string): boolean {
47
+ return key.startsWith("-----BEGIN ");
48
+ }
49
+
50
+ /**
51
+ * Derive device ID from public key per Gateway protocol.
52
+ * Gateway expects: SHA-256 hash of raw Ed25519 public key bytes, hex-encoded.
53
+ *
54
+ * The PEM contains a DER-encoded SPKI structure:
55
+ * [12 bytes algorithm info] + [32 bytes raw Ed25519 key]
56
+ */
57
+ function deriveDeviceId(publicKeyPem: string): string {
58
+ const base64 = publicKeyPem
59
+ .split("\n")
60
+ .filter(line => !line.includes("BEGIN") && !line.includes("END") && line.trim())
61
+ .join("");
62
+ const der = Buffer.from(base64, "base64");
63
+ // SPKI for Ed25519: 12-byte header + 32-byte raw key
64
+ const rawPublicKey = der.slice(12);
65
+ return createHash("sha256").update(rawPublicKey).digest("hex");
66
+ }
67
+
68
+ /**
69
+ * Write identity to disk with restrictive permissions.
70
+ */
71
+ let _windowsPermWarnShown = false;
72
+
73
+ function persistIdentity(identity: DeviceIdentity): void {
74
+ const devicePath = getDevicePath();
75
+ ensureCastleDir();
76
+ writeFileSync(devicePath, JSON.stringify(identity, null, 2), "utf-8");
77
+
78
+ if (platform() === "win32") {
79
+ // chmod is a no-op on Windows — warn once
80
+ if (!_windowsPermWarnShown) {
81
+ console.warn("[Device] Warning: On Windows, device.json file permissions cannot be restricted.");
82
+ console.warn("[Device] Keep your user account secure to protect your device private key.");
83
+ _windowsPermWarnShown = true;
84
+ }
85
+ } else {
86
+ try {
87
+ chmodSync(devicePath, 0o600);
88
+ } catch {
89
+ // Ignore — may fail on some filesystems
90
+ }
91
+ }
92
+ }
93
+
94
+ // ============================================================================
95
+ // Core API
96
+ // ============================================================================
97
+
98
+ /**
99
+ * Load existing device identity or generate a new Ed25519 keypair.
100
+ * Keys are stored in PEM format as required by the Gateway protocol.
101
+ * Identity is persisted in ~/.castle/device.json with mode 0600.
102
+ */
103
+ export function getOrCreateIdentity(): DeviceIdentity {
104
+ const devicePath = getDevicePath();
105
+
106
+ // Try loading existing identity
107
+ if (existsSync(devicePath)) {
108
+ try {
109
+ const raw = readFileSync(devicePath, "utf-8");
110
+ const identity = JSON.parse(raw) as DeviceIdentity;
111
+ if (identity.deviceId && identity.publicKey && identity.privateKey) {
112
+ // Auto-upgrade: if keys are in old hex/DER format, regenerate entirely
113
+ if (!isPem(identity.publicKey)) {
114
+ console.log("[Device] Upgrading key format from hex to PEM — regenerating keypair");
115
+ return generateIdentity();
116
+ }
117
+ // Auto-fix: if deviceId is a UUID instead of derived from public key, re-derive
118
+ const expectedId = deriveDeviceId(identity.publicKey);
119
+ if (identity.deviceId !== expectedId) {
120
+ console.log("[Device] Fixing deviceId — deriving from public key per Gateway protocol");
121
+ identity.deviceId = expectedId;
122
+ persistIdentity(identity);
123
+ }
124
+ return identity;
125
+ }
126
+ } catch {
127
+ // Corrupted file — regenerate
128
+ }
129
+ }
130
+
131
+ return generateIdentity();
132
+ }
133
+
134
+ /**
135
+ * Generate a new Ed25519 keypair and persist it.
136
+ */
137
+ function generateIdentity(): DeviceIdentity {
138
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
139
+ publicKeyEncoding: { type: "spki", format: "pem" },
140
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
141
+ });
142
+
143
+ const pubKeyStr = publicKey as unknown as string;
144
+
145
+ const identity: DeviceIdentity = {
146
+ deviceId: deriveDeviceId(pubKeyStr),
147
+ publicKey: pubKeyStr,
148
+ privateKey: privateKey as unknown as string,
149
+ createdAt: new Date().toISOString(),
150
+ };
151
+
152
+ persistIdentity(identity);
153
+ return identity;
154
+ }
155
+
156
+ /**
157
+ * Parameters for signing a device auth payload.
158
+ * Must match the Gateway's buildDeviceAuthPayload() format exactly.
159
+ */
160
+ export interface DeviceAuthSignParams {
161
+ nonce: string;
162
+ clientId: string;
163
+ clientMode: string;
164
+ role: string;
165
+ scopes: string[];
166
+ token: string;
167
+ }
168
+
169
+ /**
170
+ * Sign a device auth payload per the Gateway protocol.
171
+ *
172
+ * The Gateway builds a pipe-delimited string:
173
+ * v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
174
+ * and verifies the Ed25519 signature against that string.
175
+ *
176
+ * Returns { signature (base64url), signedAt (ms) }.
177
+ */
178
+ export function signDeviceAuth(params: DeviceAuthSignParams): {
179
+ signature: string;
180
+ signedAt: number;
181
+ } {
182
+ const identity = getOrCreateIdentity();
183
+ const signedAt = Date.now();
184
+
185
+ // Build the exact same payload string the Gateway builds
186
+ const payload = [
187
+ "v2", // version (v2 when nonce is present)
188
+ identity.deviceId,
189
+ params.clientId,
190
+ params.clientMode,
191
+ params.role,
192
+ params.scopes.join(","),
193
+ String(signedAt),
194
+ params.token,
195
+ params.nonce,
196
+ ].join("|");
197
+
198
+ // Ed25519 doesn't use a digest algorithm (pass null)
199
+ const sig = sign(null, Buffer.from(payload, "utf-8"), identity.privateKey);
200
+
201
+ // Gateway expects base64url encoding
202
+ const signature = sig
203
+ .toString("base64")
204
+ .replaceAll("+", "-")
205
+ .replaceAll("/", "_")
206
+ .replace(/=+$/g, "");
207
+
208
+ return { signature, signedAt };
209
+ }
210
+
211
+ /**
212
+ * Save a device token received after successful pairing.
213
+ */
214
+ export function saveDeviceToken(token: string, gatewayUrl?: string): void {
215
+ const identity = getOrCreateIdentity();
216
+ identity.deviceToken = token;
217
+ identity.pairedAt = new Date().toISOString();
218
+ if (gatewayUrl) {
219
+ identity.gatewayUrl = gatewayUrl;
220
+ }
221
+ persistIdentity(identity);
222
+ }
223
+
224
+ /**
225
+ * Get the saved device token, or null if not yet paired.
226
+ */
227
+ export function getDeviceToken(): string | null {
228
+ const devicePath = getDevicePath();
229
+ if (!existsSync(devicePath)) return null;
230
+
231
+ try {
232
+ const raw = readFileSync(devicePath, "utf-8");
233
+ const identity = JSON.parse(raw) as DeviceIdentity;
234
+ return identity.deviceToken || null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Clear the saved device token without deleting the identity.
242
+ * Used when a device token is rejected (e.g. Gateway was reset).
243
+ * The device keypair is preserved so it can re-pair.
244
+ */
245
+ export function clearDeviceToken(): void {
246
+ const devicePath = getDevicePath();
247
+ if (!existsSync(devicePath)) return;
248
+
249
+ try {
250
+ const raw = readFileSync(devicePath, "utf-8");
251
+ const identity = JSON.parse(raw) as DeviceIdentity;
252
+ delete identity.deviceToken;
253
+ delete identity.pairedAt;
254
+ delete identity.gatewayUrl;
255
+ persistIdentity(identity);
256
+ console.log("[Device] Cleared device token");
257
+ } catch {
258
+ // If we can't read/parse, nothing to clear
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Delete device identity entirely. Forces re-pairing on next connection.
264
+ */
265
+ export function resetIdentity(): boolean {
266
+ const devicePath = getDevicePath();
267
+ if (existsSync(devicePath)) {
268
+ unlinkSync(devicePath);
269
+ return true;
270
+ }
271
+ return false;
272
+ }
273
+
274
+ /**
275
+ * Get a summary of device identity for display (no private key).
276
+ */
277
+ export function getDeviceInfo(): DeviceInfo | null {
278
+ const devicePath = getDevicePath();
279
+ if (!existsSync(devicePath)) return null;
280
+
281
+ try {
282
+ const raw = readFileSync(devicePath, "utf-8");
283
+ const identity = JSON.parse(raw) as DeviceIdentity;
284
+
285
+ // Create a fingerprint from the public key
286
+ const fingerprint = createHash("sha256")
287
+ .update(identity.publicKey)
288
+ .digest("hex")
289
+ .slice(0, 16);
290
+
291
+ return {
292
+ deviceId: identity.deviceId,
293
+ publicKey: identity.publicKey,
294
+ fingerprint,
295
+ createdAt: identity.createdAt,
296
+ isPaired: !!identity.deviceToken,
297
+ pairedAt: identity.pairedAt,
298
+ gatewayUrl: identity.gatewayUrl,
299
+ };
300
+ } catch {
301
+ return null;
302
+ }
303
+ }