@arephan/clawdbot-memory-supabase 0.1.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.
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Clawdbot Memory (Supabase) Plugin - Simple Edition
3
+ *
4
+ * Logs EVERY message (user + assistant) to Supabase.
5
+ * Provides date-based recall for perfect memory.
6
+ * No embeddings, no vector search - just complete logging.
7
+ */
8
+
9
+ import { Type } from "@sinclair/typebox";
10
+ import { createClient, SupabaseClient } from "@supabase/supabase-js";
11
+ import { randomUUID } from "node:crypto";
12
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
13
+
14
+ // ============================================================================
15
+ // Types
16
+ // ============================================================================
17
+
18
+ type Session = {
19
+ id: string;
20
+ agent_id: string;
21
+ user_id?: string;
22
+ channel?: string;
23
+ started_at: string;
24
+ ended_at?: string;
25
+ summary?: string;
26
+ metadata: Record<string, unknown>;
27
+ };
28
+
29
+ type Message = {
30
+ id: string;
31
+ session_id: string;
32
+ role: "user" | "assistant" | "system" | "tool";
33
+ content: string;
34
+ created_at: string;
35
+ metadata: Record<string, unknown>;
36
+ };
37
+
38
+ // ============================================================================
39
+ // Config
40
+ // ============================================================================
41
+
42
+ const configSchema = {
43
+ parse: (cfg: unknown) => {
44
+ const config = cfg as {
45
+ supabase: { url: string; anonKey: string };
46
+ agentId?: string;
47
+ };
48
+ return {
49
+ supabase: config.supabase,
50
+ agentId: config.agentId ?? "hans-assistant",
51
+ };
52
+ },
53
+ };
54
+
55
+ // ============================================================================
56
+ // Supabase Client
57
+ // ============================================================================
58
+
59
+ class ConversationDB {
60
+ private client: SupabaseClient;
61
+ private agentId: string;
62
+
63
+ constructor(url: string, anonKey: string, agentId: string) {
64
+ this.client = createClient(url, anonKey);
65
+ this.agentId = agentId;
66
+ }
67
+
68
+ // Session management
69
+ async createSession(params: {
70
+ userId?: string;
71
+ channel?: string;
72
+ metadata?: Record<string, unknown>;
73
+ }): Promise<Session> {
74
+ const id = randomUUID();
75
+ const { data, error } = await this.client
76
+ .from("sessions")
77
+ .insert({
78
+ id,
79
+ agent_id: this.agentId,
80
+ user_id: params.userId,
81
+ channel: params.channel,
82
+ started_at: new Date().toISOString(),
83
+ metadata: params.metadata ?? {},
84
+ })
85
+ .select()
86
+ .single();
87
+
88
+ if (error) throw new Error(`Failed to create session: ${error.message}`);
89
+ return data as Session;
90
+ }
91
+
92
+ async endSession(sessionId: string, summary?: string): Promise<void> {
93
+ const { error } = await this.client
94
+ .from("sessions")
95
+ .update({
96
+ ended_at: new Date().toISOString(),
97
+ summary,
98
+ })
99
+ .eq("id", sessionId);
100
+
101
+ if (error) throw new Error(`Failed to end session: ${error.message}`);
102
+ }
103
+
104
+ async addMessage(params: {
105
+ sessionId: string;
106
+ role: "user" | "assistant" | "system" | "tool";
107
+ content: string;
108
+ metadata?: Record<string, unknown>;
109
+ }): Promise<Message> {
110
+ const id = randomUUID();
111
+ const created_at = new Date().toISOString();
112
+
113
+ const { data, error } = await this.client
114
+ .from("messages")
115
+ .insert({
116
+ id,
117
+ session_id: params.sessionId,
118
+ role: params.role,
119
+ content: params.content,
120
+ created_at,
121
+ metadata: params.metadata ?? {},
122
+ })
123
+ .select()
124
+ .single();
125
+
126
+ if (error) throw new Error(`Failed to add message: ${error.message}`);
127
+ return data as Message;
128
+ }
129
+
130
+ // Recall by date
131
+ async getMessagesByDate(date: string, keywords?: string): Promise<Array<Message & { session_started: string }>> {
132
+ let query = this.client
133
+ .from("messages")
134
+ .select(`
135
+ *,
136
+ sessions!inner (
137
+ agent_id,
138
+ started_at
139
+ )
140
+ `)
141
+ .eq("sessions.agent_id", this.agentId)
142
+ .gte("created_at", `${date}T00:00:00`)
143
+ .lt("created_at", `${date}T23:59:59`)
144
+ .order("created_at", { ascending: true });
145
+
146
+ if (keywords) {
147
+ query = query.ilike("content", `%${keywords}%`);
148
+ }
149
+
150
+ const { data, error } = await query;
151
+
152
+ if (error) throw new Error(`Failed to get messages: ${error.message}`);
153
+
154
+ return (data || []).map((row: any) => ({
155
+ ...row,
156
+ session_started: row.sessions?.started_at,
157
+ }));
158
+ }
159
+
160
+ // Recall by date range
161
+ async getMessagesByDateRange(
162
+ startDate: string,
163
+ endDate: string,
164
+ keywords?: string
165
+ ): Promise<Array<Message & { session_started: string }>> {
166
+ let query = this.client
167
+ .from("messages")
168
+ .select(`
169
+ *,
170
+ sessions!inner (
171
+ agent_id,
172
+ started_at
173
+ )
174
+ `)
175
+ .eq("sessions.agent_id", this.agentId)
176
+ .gte("created_at", `${startDate}T00:00:00`)
177
+ .lte("created_at", `${endDate}T23:59:59`)
178
+ .order("created_at", { ascending: true });
179
+
180
+ if (keywords) {
181
+ query = query.ilike("content", `%${keywords}%`);
182
+ }
183
+
184
+ const { data, error } = await query;
185
+
186
+ if (error) throw new Error(`Failed to get messages: ${error.message}`);
187
+
188
+ return (data || []).map((row: any) => ({
189
+ ...row,
190
+ session_started: row.sessions?.started_at,
191
+ }));
192
+ }
193
+
194
+ // Search by keywords across all time
195
+ async searchMessages(keywords: string, limit = 50): Promise<Array<Message>> {
196
+ const { data, error } = await this.client
197
+ .from("messages")
198
+ .select(`
199
+ *,
200
+ sessions!inner (
201
+ agent_id
202
+ )
203
+ `)
204
+ .eq("sessions.agent_id", this.agentId)
205
+ .ilike("content", `%${keywords}%`)
206
+ .order("created_at", { ascending: false })
207
+ .limit(limit);
208
+
209
+ if (error) throw new Error(`Failed to search messages: ${error.message}`);
210
+ return data || [];
211
+ }
212
+
213
+ // Get recent sessions
214
+ async getRecentSessions(limit = 10): Promise<Session[]> {
215
+ const { data, error } = await this.client
216
+ .from("sessions")
217
+ .select("*")
218
+ .eq("agent_id", this.agentId)
219
+ .order("started_at", { ascending: false })
220
+ .limit(limit);
221
+
222
+ if (error) throw new Error(`Failed to get sessions: ${error.message}`);
223
+ return data as Session[];
224
+ }
225
+
226
+ // Get session with all messages
227
+ async getSessionWithMessages(sessionId: string): Promise<{ session: Session; messages: Message[] } | null> {
228
+ const { data: session, error: sessionError } = await this.client
229
+ .from("sessions")
230
+ .select("*")
231
+ .eq("id", sessionId)
232
+ .single();
233
+
234
+ if (sessionError || !session) return null;
235
+
236
+ const { data: messages, error: messagesError } = await this.client
237
+ .from("messages")
238
+ .select("*")
239
+ .eq("session_id", sessionId)
240
+ .order("created_at", { ascending: true });
241
+
242
+ if (messagesError) throw new Error(`Failed to get messages: ${messagesError.message}`);
243
+
244
+ return {
245
+ session: session as Session,
246
+ messages: messages as Message[],
247
+ };
248
+ }
249
+
250
+ // Stats
251
+ async getStats(): Promise<{ sessions: number; messages: number }> {
252
+ const { count: sessions } = await this.client
253
+ .from("sessions")
254
+ .select("*", { count: "exact", head: true })
255
+ .eq("agent_id", this.agentId);
256
+
257
+ const { count: messages } = await this.client
258
+ .from("messages")
259
+ .select("*, sessions!inner(agent_id)", { count: "exact", head: true })
260
+ .eq("sessions.agent_id", this.agentId);
261
+
262
+ return {
263
+ sessions: sessions ?? 0,
264
+ messages: messages ?? 0,
265
+ };
266
+ }
267
+ }
268
+
269
+ // ============================================================================
270
+ // Plugin Definition
271
+ // ============================================================================
272
+
273
+ const memoryPlugin = {
274
+ id: "memory-supabase",
275
+ name: "Memory (Supabase)",
276
+ description: "Complete conversation logging to Supabase with date-based recall",
277
+ kind: "memory" as const,
278
+ configSchema,
279
+
280
+ register(api: ClawdbotPluginApi) {
281
+ const cfg = configSchema.parse(api.pluginConfig);
282
+ const db = new ConversationDB(cfg.supabase.url, cfg.supabase.anonKey, cfg.agentId);
283
+
284
+ let currentSessionId: string | null = null;
285
+
286
+ api.logger.info(
287
+ `memory-supabase: plugin registered (url: ${cfg.supabase.url}, agent: ${cfg.agentId})`
288
+ );
289
+
290
+ // ========================================================================
291
+ // Tools
292
+ // ========================================================================
293
+
294
+ api.registerTool(
295
+ {
296
+ name: "recall_conversation",
297
+ label: "Recall Conversation",
298
+ description:
299
+ "Recall past conversations by date. Use when user references something from a specific date or you need historical context.",
300
+ parameters: Type.Object({
301
+ date: Type.String({
302
+ description: "Date in YYYY-MM-DD format (e.g., '2026-01-15')"
303
+ }),
304
+ keywords: Type.Optional(
305
+ Type.String({ description: "Optional keywords to filter by" })
306
+ ),
307
+ }),
308
+ async execute(_toolCallId, params) {
309
+ const { date, keywords } = params as { date: string; keywords?: string };
310
+
311
+ const messages = await db.getMessagesByDate(date, keywords);
312
+
313
+ if (messages.length === 0) {
314
+ return {
315
+ content: [{
316
+ type: "text",
317
+ text: `No conversations found for ${date}${keywords ? ` with keywords "${keywords}"` : ""}.`
318
+ }],
319
+ details: { count: 0, date },
320
+ };
321
+ }
322
+
323
+ const formatted = messages.map((m) => {
324
+ const time = new Date(m.created_at).toLocaleTimeString("en-US", {
325
+ hour: "2-digit",
326
+ minute: "2-digit",
327
+ });
328
+ return `[${time}] ${m.role.toUpperCase()}: ${m.content}`;
329
+ }).join("\n\n");
330
+
331
+ return {
332
+ content: [{
333
+ type: "text",
334
+ text: `Found ${messages.length} messages from ${date}:\n\n${formatted}`
335
+ }],
336
+ details: {
337
+ count: messages.length,
338
+ date,
339
+ messages: messages.map(m => ({
340
+ role: m.role,
341
+ content: m.content.slice(0, 200),
342
+ time: m.created_at,
343
+ })),
344
+ },
345
+ };
346
+ },
347
+ },
348
+ { name: "recall_conversation" }
349
+ );
350
+
351
+ api.registerTool(
352
+ {
353
+ name: "recall_date_range",
354
+ label: "Recall Date Range",
355
+ description:
356
+ "Recall conversations across a date range. Use for 'last week', 'this month', etc.",
357
+ parameters: Type.Object({
358
+ startDate: Type.String({
359
+ description: "Start date in YYYY-MM-DD format"
360
+ }),
361
+ endDate: Type.String({
362
+ description: "End date in YYYY-MM-DD format"
363
+ }),
364
+ keywords: Type.Optional(
365
+ Type.String({ description: "Optional keywords to filter by" })
366
+ ),
367
+ }),
368
+ async execute(_toolCallId, params) {
369
+ const { startDate, endDate, keywords } = params as {
370
+ startDate: string;
371
+ endDate: string;
372
+ keywords?: string;
373
+ };
374
+
375
+ const messages = await db.getMessagesByDateRange(startDate, endDate, keywords);
376
+
377
+ if (messages.length === 0) {
378
+ return {
379
+ content: [{
380
+ type: "text",
381
+ text: `No conversations found between ${startDate} and ${endDate}${keywords ? ` with keywords "${keywords}"` : ""}.`
382
+ }],
383
+ details: { count: 0, startDate, endDate },
384
+ };
385
+ }
386
+
387
+ // Group by date
388
+ const byDate: Record<string, typeof messages> = {};
389
+ for (const m of messages) {
390
+ const date = m.created_at.split("T")[0];
391
+ if (!byDate[date]) byDate[date] = [];
392
+ byDate[date].push(m);
393
+ }
394
+
395
+ const formatted = Object.entries(byDate)
396
+ .map(([date, msgs]) => {
397
+ const lines = msgs.map((m) => {
398
+ const time = new Date(m.created_at).toLocaleTimeString("en-US", {
399
+ hour: "2-digit",
400
+ minute: "2-digit",
401
+ });
402
+ return ` [${time}] ${m.role.toUpperCase()}: ${m.content.slice(0, 150)}${m.content.length > 150 ? "..." : ""}`;
403
+ }).join("\n");
404
+ return `📅 ${date} (${msgs.length} messages):\n${lines}`;
405
+ })
406
+ .join("\n\n");
407
+
408
+ return {
409
+ content: [{
410
+ type: "text",
411
+ text: `Found ${messages.length} messages across ${Object.keys(byDate).length} days:\n\n${formatted}`
412
+ }],
413
+ details: {
414
+ count: messages.length,
415
+ days: Object.keys(byDate).length,
416
+ startDate,
417
+ endDate,
418
+ },
419
+ };
420
+ },
421
+ },
422
+ { name: "recall_date_range" }
423
+ );
424
+
425
+ api.registerTool(
426
+ {
427
+ name: "search_history",
428
+ label: "Search History",
429
+ description:
430
+ "Search all conversation history by keywords. Use when user asks about something but doesn't know the date.",
431
+ parameters: Type.Object({
432
+ keywords: Type.String({
433
+ description: "Keywords to search for"
434
+ }),
435
+ limit: Type.Optional(
436
+ Type.Number({ description: "Max results (default: 20)" })
437
+ ),
438
+ }),
439
+ async execute(_toolCallId, params) {
440
+ const { keywords, limit = 20 } = params as { keywords: string; limit?: number };
441
+
442
+ const messages = await db.searchMessages(keywords, limit);
443
+
444
+ if (messages.length === 0) {
445
+ return {
446
+ content: [{
447
+ type: "text",
448
+ text: `No messages found containing "${keywords}".`
449
+ }],
450
+ details: { count: 0, keywords },
451
+ };
452
+ }
453
+
454
+ const formatted = messages.map((m) => {
455
+ const date = new Date(m.created_at).toLocaleDateString("en-US", {
456
+ month: "short",
457
+ day: "numeric",
458
+ year: "numeric",
459
+ });
460
+ const time = new Date(m.created_at).toLocaleTimeString("en-US", {
461
+ hour: "2-digit",
462
+ minute: "2-digit",
463
+ });
464
+ return `[${date} ${time}] ${m.role.toUpperCase()}: ${m.content.slice(0, 200)}${m.content.length > 200 ? "..." : ""}`;
465
+ }).join("\n\n");
466
+
467
+ return {
468
+ content: [{
469
+ type: "text",
470
+ text: `Found ${messages.length} messages matching "${keywords}":\n\n${formatted}`
471
+ }],
472
+ details: {
473
+ count: messages.length,
474
+ keywords,
475
+ },
476
+ };
477
+ },
478
+ },
479
+ { name: "search_history" }
480
+ );
481
+
482
+ api.registerTool(
483
+ {
484
+ name: "memory_stats",
485
+ label: "Memory Stats",
486
+ description: "Show conversation logging statistics.",
487
+ parameters: Type.Object({}),
488
+ async execute() {
489
+ const stats = await db.getStats();
490
+ const sessions = await db.getRecentSessions(5);
491
+
492
+ const recentList = sessions.map((s) => {
493
+ const date = new Date(s.started_at).toLocaleDateString("en-US", {
494
+ month: "short",
495
+ day: "numeric",
496
+ });
497
+ return `- ${date}: ${s.channel || "unknown"} session`;
498
+ }).join("\n");
499
+
500
+ return {
501
+ content: [{
502
+ type: "text",
503
+ text: `📊 Memory Stats:\n- Total sessions: ${stats.sessions}\n- Total messages: ${stats.messages}\n\nRecent sessions:\n${recentList}`
504
+ }],
505
+ details: stats,
506
+ };
507
+ },
508
+ },
509
+ { name: "memory_stats" }
510
+ );
511
+
512
+ // ========================================================================
513
+ // CLI Commands
514
+ // ========================================================================
515
+
516
+ api.registerCli(
517
+ ({ program }) => {
518
+ const memory = program
519
+ .command("supamem")
520
+ .description("Supabase memory commands");
521
+
522
+ memory
523
+ .command("stats")
524
+ .description("Show memory statistics")
525
+ .action(async () => {
526
+ const stats = await db.getStats();
527
+ console.log(`Sessions: ${stats.sessions}`);
528
+ console.log(`Messages: ${stats.messages}`);
529
+ });
530
+
531
+ memory
532
+ .command("recall")
533
+ .description("Recall messages from a date")
534
+ .argument("<date>", "Date (YYYY-MM-DD)")
535
+ .option("-k, --keywords <keywords>", "Filter by keywords")
536
+ .action(async (date, opts) => {
537
+ const messages = await db.getMessagesByDate(date, opts.keywords);
538
+ for (const m of messages) {
539
+ const time = new Date(m.created_at).toLocaleTimeString();
540
+ console.log(`[${time}] ${m.role}: ${m.content.slice(0, 100)}...`);
541
+ }
542
+ });
543
+
544
+ memory
545
+ .command("search")
546
+ .description("Search messages by keywords")
547
+ .argument("<keywords>", "Keywords to search")
548
+ .option("-l, --limit <n>", "Max results", "20")
549
+ .action(async (keywords, opts) => {
550
+ const messages = await db.searchMessages(keywords, parseInt(opts.limit));
551
+ for (const m of messages) {
552
+ const date = new Date(m.created_at).toLocaleString();
553
+ console.log(`[${date}] ${m.role}: ${m.content.slice(0, 100)}...`);
554
+ }
555
+ });
556
+ },
557
+ { commands: ["supamem"] }
558
+ );
559
+
560
+ // ========================================================================
561
+ // Lifecycle Hooks - LOG EVERYTHING
562
+ // ========================================================================
563
+
564
+ // Start session when conversation begins
565
+ api.on("before_agent_start", async (event) => {
566
+ try {
567
+ const session = await db.createSession({
568
+ userId: (event as any).userId,
569
+ channel: (event as any).channel,
570
+ metadata: {
571
+ prompt: (event as any).prompt?.slice(0, 500),
572
+ startedAt: new Date().toISOString(),
573
+ },
574
+ });
575
+ currentSessionId = session.id;
576
+ api.logger.info?.(`memory-supabase: started session ${session.id}`);
577
+ } catch (err) {
578
+ api.logger.warn?.(`memory-supabase: session start failed: ${String(err)}`);
579
+ }
580
+ });
581
+
582
+ // Log all messages after agent ends
583
+ api.on("agent_end", async (event) => {
584
+ if (!currentSessionId) {
585
+ api.logger.warn?.("memory-supabase: no active session to log messages");
586
+ return;
587
+ }
588
+
589
+ try {
590
+ const messages = (event as any).messages || [];
591
+ let logged = 0;
592
+
593
+ for (const msg of messages) {
594
+ if (!msg || typeof msg !== "object") continue;
595
+
596
+ const role = (msg as any).role as string;
597
+ if (role !== "user" && role !== "assistant") continue;
598
+
599
+ const content = (msg as any).content;
600
+ let text = "";
601
+
602
+ if (typeof content === "string") {
603
+ text = content;
604
+ } else if (Array.isArray(content)) {
605
+ for (const block of content) {
606
+ if (block?.type === "text" && typeof block.text === "string") {
607
+ text += block.text;
608
+ }
609
+ }
610
+ }
611
+
612
+ if (text && text.length > 0) {
613
+ await db.addMessage({
614
+ sessionId: currentSessionId,
615
+ role: role as "user" | "assistant",
616
+ content: text,
617
+ });
618
+ logged++;
619
+ }
620
+ }
621
+
622
+ // End session
623
+ await db.endSession(currentSessionId);
624
+ api.logger.info?.(`memory-supabase: logged ${logged} messages, session ended`);
625
+ currentSessionId = null;
626
+
627
+ } catch (err) {
628
+ api.logger.warn?.(`memory-supabase: logging failed: ${String(err)}`);
629
+ }
630
+ });
631
+
632
+ // ========================================================================
633
+ // Service
634
+ // ========================================================================
635
+
636
+ api.registerService({
637
+ id: "memory-supabase",
638
+ start: () => {
639
+ api.logger.info(
640
+ `memory-supabase: initialized (logs every message to Supabase)`
641
+ );
642
+ },
643
+ stop: () => {
644
+ api.logger.info("memory-supabase: stopped");
645
+ },
646
+ });
647
+ },
648
+ };
649
+
650
+ export default memoryPlugin;