@castlekit/castle 0.3.0 → 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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/install.sh +20 -1
  3. package/package.json +17 -2
  4. package/src/app/api/openclaw/agents/route.ts +7 -1
  5. package/src/app/api/openclaw/chat/channels/route.ts +6 -3
  6. package/src/app/api/openclaw/chat/route.ts +17 -6
  7. package/src/app/api/openclaw/chat/search/route.ts +2 -1
  8. package/src/app/api/openclaw/config/route.ts +2 -0
  9. package/src/app/api/openclaw/events/route.ts +23 -8
  10. package/src/app/api/openclaw/ping/route.ts +5 -0
  11. package/src/app/api/openclaw/session/context/route.ts +163 -0
  12. package/src/app/api/openclaw/session/status/route.ts +179 -11
  13. package/src/app/api/openclaw/sessions/route.ts +2 -0
  14. package/src/app/chat/[channelId]/page.tsx +115 -35
  15. package/src/app/globals.css +10 -0
  16. package/src/app/page.tsx +10 -8
  17. package/src/components/chat/chat-input.tsx +23 -5
  18. package/src/components/chat/message-bubble.tsx +29 -13
  19. package/src/components/chat/message-list.tsx +238 -80
  20. package/src/components/chat/session-stats-panel.tsx +391 -86
  21. package/src/components/providers/search-provider.tsx +33 -4
  22. package/src/lib/db/index.ts +12 -2
  23. package/src/lib/db/queries.ts +199 -72
  24. package/src/lib/db/schema.ts +4 -0
  25. package/src/lib/gateway-connection.ts +24 -3
  26. package/src/lib/hooks/use-chat.ts +219 -241
  27. package/src/lib/hooks/use-compaction-events.ts +132 -0
  28. package/src/lib/hooks/use-context-boundary.ts +82 -0
  29. package/src/lib/hooks/use-openclaw.ts +44 -57
  30. package/src/lib/hooks/use-search.ts +1 -0
  31. package/src/lib/hooks/use-session-stats.ts +4 -1
  32. package/src/lib/sse-singleton.ts +184 -0
  33. package/src/lib/types/chat.ts +22 -6
  34. package/src/lib/db/__tests__/queries.test.ts +0 -318
  35. package/vitest.config.ts +0 -13
@@ -138,16 +138,12 @@ export function deleteChannel(id: string): boolean {
138
138
  type DrizzleDb = { session: { client: SqliteClient } };
139
139
  const sqlite = (db as unknown as DrizzleDb).session.client;
140
140
 
141
- // The FTS5 delete trigger can fail with "SQL logic error" if the FTS5 index
142
- // is out of sync with the messages table. To avoid this:
143
- // 1. Drop the FTS5 delete trigger
144
- // 2. Delete all dependent records and the channel
145
- // 3. Recreate the trigger
146
- // 4. Rebuild the FTS5 index to stay in sync
147
- sqlite.exec("DROP TRIGGER IF EXISTS messages_fts_delete");
141
+ // Delete dependent records: attachments/reactions messages sessions agents channel.
142
+ // The FTS5 delete trigger fires for each message deletion to keep FTS in sync.
143
+ // If the trigger fails (FTS out of sync), we catch and rebuild FTS afterwards.
144
+ let needsFtsRebuild = false;
148
145
 
149
146
  try {
150
- // Delete dependent records: attachments/reactions → messages → sessions → agents → channel
151
147
  const msgIds = db
152
148
  .select({ id: messages.id })
153
149
  .from(messages)
@@ -164,21 +160,36 @@ export function deleteChannel(id: string): boolean {
164
160
  .run();
165
161
  }
166
162
 
167
- db.delete(messages).where(eq(messages.channelId, id)).run();
163
+ try {
164
+ db.delete(messages).where(eq(messages.channelId, id)).run();
165
+ } catch (err) {
166
+ // FTS trigger failed — likely FTS out of sync. Delete messages without trigger,
167
+ // then rebuild FTS from scratch.
168
+ console.warn("[deleteChannel] FTS trigger failed during message deletion, will rebuild:", (err as Error).message);
169
+ sqlite.exec("DROP TRIGGER IF EXISTS messages_fts_delete");
170
+ db.delete(messages).where(eq(messages.channelId, id)).run();
171
+ needsFtsRebuild = true;
172
+ }
173
+
168
174
  db.delete(sessions).where(eq(sessions.channelId, id)).run();
169
175
  db.delete(channelAgents).where(eq(channelAgents.channelId, id)).run();
170
176
 
171
177
  const result = db.delete(channels).where(eq(channels.id, id)).run();
172
178
  return result.changes > 0;
173
179
  } finally {
174
- // Always recreate the trigger and rebuild FTS5 index
175
- sqlite.exec(`
176
- CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
177
- INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
178
- END;
179
- `);
180
- // Rebuild FTS5 to stay in sync after bulk deletion
181
- sqlite.exec("INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')");
180
+ if (needsFtsRebuild) {
181
+ // Recreate the trigger and fully resync FTS from the messages table
182
+ sqlite.exec(`
183
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
184
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
185
+ END;
186
+ `);
187
+ sqlite.exec("DELETE FROM messages_fts");
188
+ sqlite.exec(
189
+ "INSERT INTO messages_fts(rowid, content) SELECT rowid, content FROM messages"
190
+ );
191
+ console.log("[deleteChannel] FTS5 index rebuilt successfully.");
192
+ }
182
193
  }
183
194
  }
184
195
 
@@ -253,24 +264,48 @@ export function createMessage(params: {
253
264
  const id = uuid();
254
265
  const now = Date.now();
255
266
 
256
- db.insert(messages)
257
- .values({
258
- id,
259
- channelId: params.channelId,
260
- sessionId: params.sessionId ?? null,
261
- senderType: params.senderType,
262
- senderId: params.senderId,
263
- senderName: params.senderName ?? null,
264
- content: params.content,
265
- status: params.status ?? "complete",
266
- mentionedAgentId: params.mentionedAgentId ?? null,
267
- runId: params.runId ?? null,
268
- sessionKey: params.sessionKey ?? null,
269
- inputTokens: params.inputTokens ?? null,
270
- outputTokens: params.outputTokens ?? null,
271
- createdAt: now,
272
- })
273
- .run();
267
+ const insertValues = {
268
+ id,
269
+ channelId: params.channelId,
270
+ sessionId: params.sessionId ?? null,
271
+ senderType: params.senderType,
272
+ senderId: params.senderId,
273
+ senderName: params.senderName ?? null,
274
+ content: params.content,
275
+ status: params.status ?? "complete",
276
+ mentionedAgentId: params.mentionedAgentId ?? null,
277
+ runId: params.runId ?? null,
278
+ sessionKey: params.sessionKey ?? null,
279
+ inputTokens: params.inputTokens ?? null,
280
+ outputTokens: params.outputTokens ?? null,
281
+ createdAt: now,
282
+ };
283
+
284
+ try {
285
+ db.insert(messages).values(insertValues).run();
286
+ } catch (err) {
287
+ const code = (err as { code?: string }).code;
288
+
289
+ // FTS5 trigger can fail with SQLITE_CONSTRAINT_PRIMARYKEY if the FTS index
290
+ // has orphaned entries from a previous bug. Auto-repair: rebuild FTS and retry.
291
+ if (code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
292
+ console.warn(
293
+ `[createMessage] FTS constraint error — rebuilding FTS index and retrying (id=${id}, channelId=${params.channelId})`
294
+ );
295
+ type SqliteClient = { exec: (sql: string) => void };
296
+ type DrizzleDb = { session: { client: SqliteClient } };
297
+ const sqlite = (db as unknown as DrizzleDb).session.client;
298
+ sqlite.exec("DELETE FROM messages_fts");
299
+ sqlite.exec(
300
+ "INSERT INTO messages_fts(rowid, content) SELECT rowid, content FROM messages"
301
+ );
302
+ // Retry the insert — FTS is now clean
303
+ db.insert(messages).values(insertValues).run();
304
+ console.log("[createMessage] Retry succeeded after FTS rebuild.");
305
+ } else {
306
+ throw err;
307
+ }
308
+ }
274
309
 
275
310
  return {
276
311
  id,
@@ -304,18 +339,69 @@ export function updateMessage(
304
339
  }
305
340
  ): boolean {
306
341
  const db = getDb();
307
- const result = db
308
- .update(messages)
309
- .set(updates)
310
- .where(eq(messages.id, id))
311
- .run();
312
- return result.changes > 0;
342
+ try {
343
+ const result = db
344
+ .update(messages)
345
+ .set(updates)
346
+ .where(eq(messages.id, id))
347
+ .run();
348
+ return result.changes > 0;
349
+ } catch (err) {
350
+ const code = (err as { code?: string }).code;
351
+ // FTS5 update trigger can fail if the index is out of sync.
352
+ // Auto-repair: drop trigger, update, rebuild FTS, recreate trigger.
353
+ if (code === "SQLITE_ERROR" && updates.content !== undefined) {
354
+ console.warn(`[DB] updateMessage FTS error — dropping trigger, retrying, rebuilding (id=${id})`);
355
+ type SqliteClient = { exec: (sql: string) => void };
356
+ type DrizzleDb = { session: { client: SqliteClient } };
357
+ const sqlite = (db as unknown as DrizzleDb).session.client;
358
+ sqlite.exec("DROP TRIGGER IF EXISTS messages_fts_update");
359
+ const result = db.update(messages).set(updates).where(eq(messages.id, id)).run();
360
+ sqlite.exec(`
361
+ CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE OF content ON messages BEGIN
362
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
363
+ INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
364
+ END;
365
+ `);
366
+ sqlite.exec("DELETE FROM messages_fts");
367
+ sqlite.exec("INSERT INTO messages_fts(rowid, content) SELECT rowid, content FROM messages");
368
+ console.log("[DB] updateMessage retry succeeded after FTS rebuild.");
369
+ return result.changes > 0;
370
+ }
371
+ console.error(`[DB] updateMessage FAILED — id=${id} keys=${Object.keys(updates).join(",")}:`, (err as Error).message);
372
+ throw err;
373
+ }
313
374
  }
314
375
 
315
376
  export function deleteMessage(id: string): boolean {
316
377
  const db = getDb();
317
- const result = db.delete(messages).where(eq(messages.id, id)).run();
318
- return result.changes > 0;
378
+ try {
379
+ const result = db.delete(messages).where(eq(messages.id, id)).run();
380
+ return result.changes > 0;
381
+ } catch (err) {
382
+ const code = (err as { code?: string }).code;
383
+ // FTS5 delete trigger can fail if the index is out of sync.
384
+ // Auto-repair: drop trigger, delete, rebuild FTS, recreate trigger.
385
+ if (code === "SQLITE_ERROR") {
386
+ console.warn(`[DB] deleteMessage FTS error — dropping trigger, retrying, rebuilding (id=${id})`);
387
+ type SqliteClient = { exec: (sql: string) => void };
388
+ type DrizzleDb = { session: { client: SqliteClient } };
389
+ const sqlite = (db as unknown as DrizzleDb).session.client;
390
+ sqlite.exec("DROP TRIGGER IF EXISTS messages_fts_delete");
391
+ const result = db.delete(messages).where(eq(messages.id, id)).run();
392
+ sqlite.exec(`
393
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
394
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
395
+ END;
396
+ `);
397
+ sqlite.exec("DELETE FROM messages_fts");
398
+ sqlite.exec("INSERT INTO messages_fts(rowid, content) SELECT rowid, content FROM messages");
399
+ console.log("[DB] deleteMessage retry succeeded after FTS rebuild.");
400
+ return result.changes > 0;
401
+ }
402
+ console.error(`[DB] deleteMessage FAILED — id=${id}:`, (err as Error).message);
403
+ throw err;
404
+ }
319
405
  }
320
406
 
321
407
  /**
@@ -556,14 +642,19 @@ export function createSession(params: {
556
642
  const id = uuid();
557
643
  const now = Date.now();
558
644
 
559
- db.insert(sessions)
560
- .values({
561
- id,
562
- channelId: params.channelId,
563
- sessionKey: params.sessionKey ?? null,
564
- startedAt: now,
565
- })
566
- .run();
645
+ try {
646
+ db.insert(sessions)
647
+ .values({
648
+ id,
649
+ channelId: params.channelId,
650
+ sessionKey: params.sessionKey ?? null,
651
+ startedAt: now,
652
+ })
653
+ .run();
654
+ } catch (err) {
655
+ console.error(`[DB] createSession FAILED — channelId=${params.channelId} sessionKey=${params.sessionKey}:`, (err as Error).message);
656
+ throw err;
657
+ }
567
658
 
568
659
  return {
569
660
  id,
@@ -628,6 +719,37 @@ export function getLatestSessionKey(channelId: string): string | null {
628
719
  return row?.sessionKey ?? null;
629
720
  }
630
721
 
722
+ /**
723
+ * Update the compaction boundary for a session (by session key).
724
+ * The boundary message ID is the oldest message still in the agent's context.
725
+ */
726
+ export function setCompactionBoundary(
727
+ sessionKey: string,
728
+ boundaryMessageId: string
729
+ ): boolean {
730
+ const db = getDb();
731
+ const result = db
732
+ .update(sessions)
733
+ .set({ compactionBoundaryMessageId: boundaryMessageId })
734
+ .where(eq(sessions.sessionKey, sessionKey))
735
+ .run();
736
+ return result.changes > 0;
737
+ }
738
+
739
+ /**
740
+ * Get the compaction boundary message ID for a session (by session key).
741
+ */
742
+ export function getCompactionBoundary(sessionKey: string): string | null {
743
+ const db = getDb();
744
+ const row = db
745
+ .select({ boundaryId: sessions.compactionBoundaryMessageId })
746
+ .from(sessions)
747
+ .where(eq(sessions.sessionKey, sessionKey))
748
+ .limit(1)
749
+ .get();
750
+ return row?.boundaryId ?? null;
751
+ }
752
+
631
753
  // ============================================================================
632
754
  // Attachments
633
755
  // ============================================================================
@@ -779,27 +901,32 @@ export function searchMessages(
779
901
 
780
902
  let rows: Record<string, unknown>[];
781
903
 
782
- if (channelId) {
783
- const stmt = sqlite.prepare(`
784
- SELECT m.*
785
- FROM messages m
786
- JOIN messages_fts fts ON m.rowid = fts.rowid
787
- WHERE messages_fts MATCH ?
788
- AND m.channel_id = ?
789
- ORDER BY m.created_at DESC
790
- LIMIT ?
791
- `);
792
- rows = stmt.all(sanitizedQuery, channelId, limit);
793
- } else {
794
- const stmt = sqlite.prepare(`
795
- SELECT m.*
796
- FROM messages m
797
- JOIN messages_fts fts ON m.rowid = fts.rowid
798
- WHERE messages_fts MATCH ?
799
- ORDER BY m.created_at DESC
800
- LIMIT ?
801
- `);
802
- rows = stmt.all(sanitizedQuery, limit);
904
+ try {
905
+ if (channelId) {
906
+ const stmt = sqlite.prepare(`
907
+ SELECT m.*
908
+ FROM messages m
909
+ JOIN messages_fts fts ON m.rowid = fts.rowid
910
+ WHERE messages_fts MATCH ?
911
+ AND m.channel_id = ?
912
+ ORDER BY m.created_at DESC
913
+ LIMIT ?
914
+ `);
915
+ rows = stmt.all(sanitizedQuery, channelId, limit);
916
+ } else {
917
+ const stmt = sqlite.prepare(`
918
+ SELECT m.*
919
+ FROM messages m
920
+ JOIN messages_fts fts ON m.rowid = fts.rowid
921
+ WHERE messages_fts MATCH ?
922
+ ORDER BY m.created_at DESC
923
+ LIMIT ?
924
+ `);
925
+ rows = stmt.all(sanitizedQuery, limit);
926
+ }
927
+ } catch (err) {
928
+ console.error(`[DB] searchMessages FTS FAILED — query="${sanitizedQuery}" channelId=${channelId}:`, (err as Error).message);
929
+ return [];
803
930
  }
804
931
 
805
932
  return rows.map((row) => ({
@@ -68,6 +68,10 @@ export const sessions = sqliteTable(
68
68
  summary: text("summary"),
69
69
  totalInputTokens: integer("total_input_tokens").default(0),
70
70
  totalOutputTokens: integer("total_output_tokens").default(0),
71
+ // Compaction tracking: ID of the oldest message still in the agent's context.
72
+ // Messages before this boundary have been compacted (summarized).
73
+ // Updated when compaction events are detected.
74
+ compactionBoundaryMessageId: text("compaction_boundary_message_id"),
71
75
  },
72
76
  (table) => [
73
77
  index("idx_sessions_channel").on(table.channelId, table.startedAt),
@@ -463,7 +463,8 @@ class GatewayConnection extends EventEmitter {
463
463
  let msg: GatewayFrame;
464
464
  try {
465
465
  msg = JSON.parse(data.toString());
466
- } catch {
466
+ } catch (err) {
467
+ console.warn("[Gateway] Failed to parse message:", (err as Error).message);
467
468
  return;
468
469
  }
469
470
 
@@ -502,16 +503,31 @@ class GatewayConnection extends EventEmitter {
502
503
 
503
504
  const id = randomUUID();
504
505
  const frame: RequestFrame = { type: "req", id, method, params };
506
+ const startTime = Date.now();
505
507
 
506
508
  return new Promise<T>((resolve, reject) => {
507
509
  const timer = setTimeout(() => {
508
510
  this.pending.delete(id);
511
+ const elapsed = Date.now() - startTime;
512
+ console.error(`[Gateway RPC] ${method} TIMEOUT after ${elapsed}ms`);
509
513
  reject(new Error(`Request timeout: ${method}`));
510
514
  }, this.requestTimeout);
511
515
 
512
516
  this.pending.set(id, {
513
- resolve: resolve as (payload: unknown) => void,
514
- reject,
517
+ resolve: (payload: unknown) => {
518
+ const elapsed = Date.now() - startTime;
519
+ if (elapsed > 2000) {
520
+ console.warn(`[Gateway RPC] ${method} OK (slow: ${elapsed}ms)`);
521
+ } else {
522
+ console.log(`[Gateway RPC] ${method} OK (${elapsed}ms)`);
523
+ }
524
+ resolve(payload as T);
525
+ },
526
+ reject: (error: Error) => {
527
+ const elapsed = Date.now() - startTime;
528
+ console.error(`[Gateway RPC] ${method} FAILED (${elapsed}ms): ${sanitize(error.message)}`);
529
+ reject(error);
530
+ },
515
531
  timer,
516
532
  });
517
533
 
@@ -519,6 +535,8 @@ class GatewayConnection extends EventEmitter {
519
535
  if (err) {
520
536
  clearTimeout(timer);
521
537
  this.pending.delete(id);
538
+ const elapsed = Date.now() - startTime;
539
+ console.error(`[Gateway RPC] ${method} SEND ERROR (${elapsed}ms): ${err.message}`);
522
540
  reject(new Error(`Send failed: ${err.message}`));
523
541
  }
524
542
  });
@@ -556,6 +574,9 @@ class GatewayConnection extends EventEmitter {
556
574
  }
557
575
 
558
576
  // Reject all pending requests
577
+ if (this.pending.size > 0) {
578
+ console.warn(`[Gateway] Rejecting ${this.pending.size} pending request(s) due to connection close`);
579
+ }
559
580
  for (const [id, pending] of this.pending) {
560
581
  clearTimeout(pending.timer);
561
582
  pending.reject(new Error("Connection closed"));