@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.
- package/LICENSE +21 -0
- package/install.sh +20 -1
- package/package.json +17 -2
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/chat/channels/route.ts +6 -3
- package/src/app/api/openclaw/chat/route.ts +17 -6
- package/src/app/api/openclaw/chat/search/route.ts +2 -1
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +179 -11
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/chat/[channelId]/page.tsx +115 -35
- package/src/app/globals.css +10 -0
- package/src/app/page.tsx +10 -8
- package/src/components/chat/chat-input.tsx +23 -5
- package/src/components/chat/message-bubble.tsx +29 -13
- package/src/components/chat/message-list.tsx +238 -80
- package/src/components/chat/session-stats-panel.tsx +391 -86
- package/src/components/providers/search-provider.tsx +33 -4
- package/src/lib/db/index.ts +12 -2
- package/src/lib/db/queries.ts +199 -72
- package/src/lib/db/schema.ts +4 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-chat.ts +219 -241
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +44 -57
- package/src/lib/hooks/use-search.ts +1 -0
- package/src/lib/hooks/use-session-stats.ts +4 -1
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +22 -6
- package/src/lib/db/__tests__/queries.test.ts +0 -318
- package/vitest.config.ts +0 -13
package/src/lib/db/queries.ts
CHANGED
|
@@ -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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
560
|
-
.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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) => ({
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -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:
|
|
514
|
-
|
|
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"));
|