ccrecall 0.0.4 → 0.0.6

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/README.md CHANGED
@@ -63,10 +63,14 @@ bun src/index.ts stats
63
63
 
64
64
  ### Commands
65
65
 
66
- | Command | Description |
67
- | ------- | ------------------------------------------ |
68
- | `sync` | Import transcripts and teams (incremental) |
69
- | `stats` | Show session/message/team/token counts |
66
+ | Command | Description |
67
+ | ---------- | ------------------------------------------ |
68
+ | `sync` | Import transcripts and teams (incremental) |
69
+ | `stats` | Show session/message/team/token counts |
70
+ | `sessions` | List recent sessions |
71
+ | `search` | Full-text search across messages |
72
+ | `tools` | Show most-used tools |
73
+ | `query` | Execute raw SQL against the database |
70
74
 
71
75
  ### Options
72
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccrecall",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Sync Claude Code transcripts to SQLite and recall context from past sessions",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -92,6 +92,331 @@ Database: ${db_path}
92
92
  },
93
93
  });
94
94
 
95
+ export const query = defineCommand({
96
+ meta: {
97
+ name: 'query',
98
+ description: 'Execute raw SQL against the database',
99
+ },
100
+ args: {
101
+ ...sharedArgs,
102
+ sql: {
103
+ type: 'positional' as const,
104
+ description: 'SQL query to execute',
105
+ required: true,
106
+ },
107
+ format: {
108
+ type: 'string',
109
+ alias: 'f',
110
+ description: 'Output format: table, json, csv (default: table)',
111
+ },
112
+ limit: {
113
+ type: 'string',
114
+ alias: 'l',
115
+ description: 'Limit rows (appends LIMIT clause if not present)',
116
+ },
117
+ },
118
+ async run({ args }) {
119
+ const { Database: BunDB } = await import('bun:sqlite');
120
+ const { existsSync } = await import('fs');
121
+
122
+ const db_path = args.db ?? DEFAULT_DB_PATH;
123
+ if (!existsSync(db_path)) {
124
+ console.error(`Database not found: ${db_path}`);
125
+ process.exit(1);
126
+ }
127
+
128
+ const db = new BunDB(db_path, { readonly: true });
129
+
130
+ try {
131
+ let sql = args.sql;
132
+ const format = args.format ?? 'table';
133
+
134
+ // Add LIMIT if specified and not already present
135
+ if (args.limit && !/\bLIMIT\b/i.test(sql)) {
136
+ sql = `${sql.replace(/;?\s*$/, '')} LIMIT ${parseInt(args.limit, 10)}`;
137
+ }
138
+
139
+ const rows = db.prepare(sql).all() as Record<string, unknown>[];
140
+
141
+ if (rows.length === 0) {
142
+ console.log('No results.');
143
+ return;
144
+ }
145
+
146
+ const columns = Object.keys(rows[0]);
147
+
148
+ if (format === 'json') {
149
+ console.log(JSON.stringify(rows, null, 2));
150
+ } else if (format === 'csv') {
151
+ console.log(columns.join(','));
152
+ for (const row of rows) {
153
+ const values = columns.map((c) => {
154
+ const v = row[c];
155
+ if (v === null) return '';
156
+ const s = String(v);
157
+ return s.includes(',') ||
158
+ s.includes('"') ||
159
+ s.includes('\n')
160
+ ? `"${s.replace(/"/g, '""')}"`
161
+ : s;
162
+ });
163
+ console.log(values.join(','));
164
+ }
165
+ } else {
166
+ // table format
167
+ const widths = columns.map((c) =>
168
+ Math.max(
169
+ c.length,
170
+ ...rows.map(
171
+ (r) => String(r[c] ?? '').slice(0, 50).length,
172
+ ),
173
+ ),
174
+ );
175
+
176
+ const header = columns
177
+ .map((c, i) => c.padEnd(widths[i]))
178
+ .join(' | ');
179
+ const sep = widths.map((w) => '-'.repeat(w)).join('-+-');
180
+
181
+ console.log(header);
182
+ console.log(sep);
183
+ for (const row of rows) {
184
+ const line = columns
185
+ .map((c, i) =>
186
+ String(row[c] ?? '')
187
+ .slice(0, 50)
188
+ .padEnd(widths[i]),
189
+ )
190
+ .join(' | ');
191
+ console.log(line);
192
+ }
193
+ console.log(`\n${rows.length} row(s)`);
194
+ }
195
+ } catch (err) {
196
+ console.error('SQL error:', (err as Error).message);
197
+ process.exit(1);
198
+ } finally {
199
+ db.close();
200
+ }
201
+ },
202
+ });
203
+
204
+ export const tools = defineCommand({
205
+ meta: {
206
+ name: 'tools',
207
+ description: 'Show most-used tools',
208
+ },
209
+ args: {
210
+ ...sharedArgs,
211
+ top: {
212
+ type: 'string',
213
+ alias: 't',
214
+ description: 'Number of tools to show (default: 10)',
215
+ },
216
+ project: {
217
+ type: 'string',
218
+ alias: 'p',
219
+ description: 'Filter by project path',
220
+ },
221
+ format: {
222
+ type: 'string',
223
+ alias: 'f',
224
+ description: 'Output format: table, json (default: table)',
225
+ },
226
+ },
227
+ async run({ args }) {
228
+ const { Database } = await import('./db.ts');
229
+
230
+ const db_path = args.db ?? DEFAULT_DB_PATH;
231
+ const db = new Database(db_path);
232
+
233
+ try {
234
+ const results = db.get_tool_stats({
235
+ limit: args.top ? parseInt(args.top, 10) : undefined,
236
+ project: args.project,
237
+ });
238
+
239
+ if (results.length === 0) {
240
+ console.log('No tool usage data found.');
241
+ return;
242
+ }
243
+
244
+ if (args.format === 'json') {
245
+ console.log(JSON.stringify(results, null, 2));
246
+ return;
247
+ }
248
+
249
+ const maxNameLen = Math.max(
250
+ 4,
251
+ ...results.map((r) => r.tool_name.length),
252
+ );
253
+ const maxCountLen = Math.max(
254
+ 5,
255
+ ...results.map((r) => r.count.toString().length),
256
+ );
257
+
258
+ console.log(
259
+ `${'Tool'.padEnd(maxNameLen)} ${'Count'.padStart(maxCountLen)} %`,
260
+ );
261
+ console.log(
262
+ `${'-'.repeat(maxNameLen)} ${'-'.repeat(maxCountLen)} ------`,
263
+ );
264
+
265
+ for (const r of results) {
266
+ console.log(
267
+ `${r.tool_name.padEnd(maxNameLen)} ${r.count.toString().padStart(maxCountLen)} ${r.percentage.toFixed(1).padStart(5)}%`,
268
+ );
269
+ }
270
+ } finally {
271
+ db.close();
272
+ }
273
+ },
274
+ });
275
+
276
+ export const search = defineCommand({
277
+ meta: {
278
+ name: 'search',
279
+ description: 'Full-text search across messages',
280
+ },
281
+ args: {
282
+ ...sharedArgs,
283
+ term: {
284
+ type: 'positional' as const,
285
+ description:
286
+ 'Search term (supports FTS5 syntax: AND, OR, NOT, "phrase", prefix*)',
287
+ required: true,
288
+ },
289
+ limit: {
290
+ type: 'string',
291
+ alias: 'l',
292
+ description: 'Maximum results (default: 20)',
293
+ },
294
+ project: {
295
+ type: 'string',
296
+ alias: 'p',
297
+ description: 'Filter by project path',
298
+ },
299
+ rebuild: {
300
+ type: 'boolean',
301
+ description: 'Rebuild FTS index before searching',
302
+ },
303
+ },
304
+ async run({ args }) {
305
+ const { Database } = await import('./db.ts');
306
+
307
+ const db_path = args.db ?? DEFAULT_DB_PATH;
308
+ const db = new Database(db_path);
309
+
310
+ try {
311
+ if (args.rebuild) {
312
+ console.log('Rebuilding FTS index...');
313
+ db.rebuild_fts();
314
+ }
315
+
316
+ const results = db.search(args.term, {
317
+ limit: args.limit ? parseInt(args.limit, 10) : undefined,
318
+ project: args.project,
319
+ });
320
+
321
+ if (results.length === 0) {
322
+ console.log('No matches found.');
323
+ return;
324
+ }
325
+
326
+ console.log(`Found ${results.length} matches:\n`);
327
+
328
+ for (const r of results) {
329
+ const date = new Date(r.timestamp)
330
+ .toISOString()
331
+ .split('T')[0];
332
+ const project = r.project_path.split('/').slice(-2).join('/');
333
+ console.log(`[${date}] ${project}`);
334
+ console.log(` ${r.snippet.replace(/\n/g, ' ')}`);
335
+ console.log(` session: ${r.session_id.slice(0, 8)}...\n`);
336
+ }
337
+ } finally {
338
+ db.close();
339
+ }
340
+ },
341
+ });
342
+
343
+ export const sessions = defineCommand({
344
+ meta: {
345
+ name: 'sessions',
346
+ description: 'List recent sessions',
347
+ },
348
+ args: {
349
+ ...sharedArgs,
350
+ limit: {
351
+ type: 'string',
352
+ alias: 'l',
353
+ description: 'Maximum sessions to show (default: 10)',
354
+ },
355
+ project: {
356
+ type: 'string',
357
+ alias: 'p',
358
+ description: 'Filter by project path',
359
+ },
360
+ format: {
361
+ type: 'string',
362
+ alias: 'f',
363
+ description: 'Output format: table or json (default: table)',
364
+ },
365
+ },
366
+ async run({ args }) {
367
+ const { Database } = await import('./db.ts');
368
+
369
+ const db_path = args.db ?? DEFAULT_DB_PATH;
370
+ const db = new Database(db_path);
371
+
372
+ try {
373
+ const results = db.get_sessions({
374
+ limit: args.limit ? parseInt(args.limit, 10) : undefined,
375
+ project: args.project,
376
+ });
377
+
378
+ if (results.length === 0) {
379
+ console.log('No sessions found.');
380
+ return;
381
+ }
382
+
383
+ if (args.format === 'json') {
384
+ console.log(JSON.stringify(results, null, 2));
385
+ return;
386
+ }
387
+
388
+ // Table format
389
+ console.log(
390
+ 'Date | Project | Msgs | Tokens | Duration',
391
+ );
392
+ console.log(
393
+ '-----------|----------------------------------|------|-----------|----------',
394
+ );
395
+
396
+ for (const s of results) {
397
+ const date = new Date(s.first_timestamp)
398
+ .toISOString()
399
+ .split('T')[0];
400
+ const project = s.project_path
401
+ .split('/')
402
+ .slice(-2)
403
+ .join('/')
404
+ .padEnd(32)
405
+ .slice(0, 32);
406
+ const msgs = String(s.message_count).padStart(4);
407
+ const tokens = s.total_tokens.toLocaleString().padStart(9);
408
+ const duration =
409
+ s.duration_mins > 0 ? `${s.duration_mins}m` : '<1m';
410
+ console.log(
411
+ `${date} | ${project} | ${msgs} | ${tokens} | ${duration.padStart(8)}`,
412
+ );
413
+ }
414
+ } finally {
415
+ db.close();
416
+ }
417
+ },
418
+ });
419
+
95
420
  export const main = defineCommand({
96
421
  meta: {
97
422
  name: 'ccrecall',
@@ -109,5 +434,9 @@ export const main = defineCommand({
109
434
  subCommands: {
110
435
  sync,
111
436
  stats,
437
+ search,
438
+ sessions,
439
+ query,
440
+ tools,
112
441
  },
113
442
  });
package/src/db.ts CHANGED
@@ -119,8 +119,55 @@ CREATE INDEX IF NOT EXISTS idx_teams_lead_session ON teams(lead_session_id);
119
119
  CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
120
120
  CREATE INDEX IF NOT EXISTS idx_team_tasks_team ON team_tasks(team_id);
121
121
  CREATE INDEX IF NOT EXISTS idx_team_tasks_status ON team_tasks(status);
122
+
123
+ -- FTS5 full-text search index for messages
124
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
125
+ content_text,
126
+ content='messages',
127
+ content_rowid='rowid'
128
+ );
129
+
130
+ -- Triggers to keep FTS index in sync with messages table
131
+ CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
132
+ INSERT INTO messages_fts(rowid, content_text) VALUES (new.rowid, new.content_text);
133
+ END;
134
+
135
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
136
+ INSERT INTO messages_fts(messages_fts, rowid, content_text) VALUES('delete', old.rowid, old.content_text);
137
+ END;
138
+
139
+ CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
140
+ INSERT INTO messages_fts(messages_fts, rowid, content_text) VALUES('delete', old.rowid, old.content_text);
141
+ INSERT INTO messages_fts(rowid, content_text) VALUES (new.rowid, new.content_text);
142
+ END;
122
143
  `;
123
144
 
145
+ /**
146
+ * Escape a search term for FTS5 MATCH queries.
147
+ * Handles special characters while preserving prefix (*) and phrase ("") searches.
148
+ */
149
+ function escape_fts5_query(term: string): string {
150
+ // If already a phrase query (wrapped in quotes), just escape internal quotes
151
+ if (term.startsWith('"') && term.endsWith('"')) {
152
+ return term;
153
+ }
154
+
155
+ // Check for prefix search (ends with *)
156
+ const is_prefix = term.endsWith('*');
157
+ const base_term = is_prefix ? term.slice(0, -1) : term;
158
+
159
+ // FTS5 special chars that cause syntax errors
160
+ const has_special = /[/\-:()^]/.test(base_term);
161
+
162
+ if (!has_special && !base_term.includes('"')) {
163
+ return term; // Safe as-is
164
+ }
165
+
166
+ // Escape by wrapping in quotes (double internal quotes)
167
+ const escaped = `"${base_term.replace(/"/g, '""')}"`;
168
+ return is_prefix ? escaped + '*' : escaped;
169
+ }
170
+
124
171
  export class Database {
125
172
  private db: BunDB;
126
173
  private stmt_upsert_session: Statement;
@@ -444,6 +491,147 @@ export class Database {
444
491
  this.db.run('DELETE FROM sync_state');
445
492
  }
446
493
 
494
+ search(
495
+ term: string,
496
+ options: { limit?: number; project?: string } = {},
497
+ ): Array<{
498
+ uuid: string;
499
+ session_id: string;
500
+ project_path: string;
501
+ content_text: string;
502
+ timestamp: number;
503
+ snippet: string;
504
+ }> {
505
+ const limit = options.limit ?? 20;
506
+ let query = `
507
+ SELECT
508
+ m.uuid,
509
+ m.session_id,
510
+ s.project_path,
511
+ m.content_text,
512
+ m.timestamp,
513
+ snippet(messages_fts, 0, '>>>', '<<<', '...', 32) as snippet
514
+ FROM messages_fts
515
+ JOIN messages m ON m.rowid = messages_fts.rowid
516
+ JOIN sessions s ON s.id = m.session_id
517
+ WHERE messages_fts MATCH ?
518
+ `;
519
+ const params: (string | number)[] = [escape_fts5_query(term)];
520
+
521
+ if (options.project) {
522
+ query += ` AND s.project_path LIKE ?`;
523
+ params.push(`%${options.project}%`);
524
+ }
525
+
526
+ query += ` ORDER BY rank LIMIT ?`;
527
+ params.push(limit);
528
+
529
+ return this.db.prepare(query).all(...params) as Array<{
530
+ uuid: string;
531
+ session_id: string;
532
+ project_path: string;
533
+ content_text: string;
534
+ timestamp: number;
535
+ snippet: string;
536
+ }>;
537
+ }
538
+
539
+ rebuild_fts() {
540
+ this.db.run(
541
+ `INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`,
542
+ );
543
+ }
544
+
545
+ get_sessions(
546
+ options: { limit?: number; project?: string } = {},
547
+ ): Array<{
548
+ id: string;
549
+ project_path: string;
550
+ first_timestamp: number;
551
+ last_timestamp: number;
552
+ message_count: number;
553
+ total_tokens: number;
554
+ duration_mins: number;
555
+ }> {
556
+ const limit = options.limit ?? 10;
557
+ let query = `
558
+ SELECT
559
+ s.id,
560
+ s.project_path,
561
+ s.first_timestamp,
562
+ s.last_timestamp,
563
+ COUNT(m.uuid) as message_count,
564
+ COALESCE(SUM(m.input_tokens + m.output_tokens), 0) as total_tokens,
565
+ CAST((s.last_timestamp - s.first_timestamp) / 60000.0 AS INTEGER) as duration_mins
566
+ FROM sessions s
567
+ LEFT JOIN messages m ON m.session_id = s.id
568
+ `;
569
+ const params: (string | number)[] = [];
570
+
571
+ if (options.project) {
572
+ query += ` WHERE s.project_path LIKE ?`;
573
+ params.push(`%${options.project}%`);
574
+ }
575
+
576
+ query += ` GROUP BY s.id ORDER BY s.last_timestamp DESC LIMIT ?`;
577
+ params.push(limit);
578
+
579
+ return this.db.prepare(query).all(...params) as Array<{
580
+ id: string;
581
+ project_path: string;
582
+ first_timestamp: number;
583
+ last_timestamp: number;
584
+ message_count: number;
585
+ total_tokens: number;
586
+ duration_mins: number;
587
+ }>;
588
+ }
589
+
590
+ get_tool_stats(
591
+ options: { limit?: number; project?: string } = {},
592
+ ): Array<{
593
+ tool_name: string;
594
+ count: number;
595
+ percentage: number;
596
+ }> {
597
+ const limit = options.limit ?? 10;
598
+ let query = `
599
+ SELECT
600
+ tc.tool_name,
601
+ COUNT(*) as count
602
+ FROM tool_calls tc
603
+ `;
604
+ const params: (string | number)[] = [];
605
+
606
+ if (options.project) {
607
+ query += `
608
+ JOIN sessions s ON s.id = tc.session_id
609
+ WHERE s.project_path LIKE ?
610
+ `;
611
+ params.push(`%${options.project}%`);
612
+ }
613
+
614
+ query += `
615
+ GROUP BY tc.tool_name
616
+ ORDER BY count DESC
617
+ LIMIT ?
618
+ `;
619
+ params.push(limit);
620
+
621
+ const rows = this.db.prepare(query).all(...params) as Array<{
622
+ tool_name: string;
623
+ count: number;
624
+ }>;
625
+
626
+ const total = rows.reduce((sum, r) => sum + r.count, 0);
627
+
628
+ return rows.map((r) => ({
629
+ tool_name: r.tool_name,
630
+ count: r.count,
631
+ percentage: total > 0 ? (r.count / total) * 100 : 0,
632
+ }));
633
+ }
634
+
447
635
  close() {
448
636
  this.db.close();
449
637
  }