ccrecall 0.0.4 → 0.0.5

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +329 -0
  3. package/src/db.ts +162 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccrecall",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
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,6 +119,27 @@ 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
 
124
145
  export class Database {
@@ -444,6 +465,147 @@ export class Database {
444
465
  this.db.run('DELETE FROM sync_state');
445
466
  }
446
467
 
468
+ search(
469
+ term: string,
470
+ options: { limit?: number; project?: string } = {},
471
+ ): Array<{
472
+ uuid: string;
473
+ session_id: string;
474
+ project_path: string;
475
+ content_text: string;
476
+ timestamp: number;
477
+ snippet: string;
478
+ }> {
479
+ const limit = options.limit ?? 20;
480
+ let query = `
481
+ SELECT
482
+ m.uuid,
483
+ m.session_id,
484
+ s.project_path,
485
+ m.content_text,
486
+ m.timestamp,
487
+ snippet(messages_fts, 0, '>>>', '<<<', '...', 32) as snippet
488
+ FROM messages_fts
489
+ JOIN messages m ON m.rowid = messages_fts.rowid
490
+ JOIN sessions s ON s.id = m.session_id
491
+ WHERE messages_fts MATCH ?
492
+ `;
493
+ const params: (string | number)[] = [term];
494
+
495
+ if (options.project) {
496
+ query += ` AND s.project_path LIKE ?`;
497
+ params.push(`%${options.project}%`);
498
+ }
499
+
500
+ query += ` ORDER BY rank LIMIT ?`;
501
+ params.push(limit);
502
+
503
+ return this.db.prepare(query).all(...params) as Array<{
504
+ uuid: string;
505
+ session_id: string;
506
+ project_path: string;
507
+ content_text: string;
508
+ timestamp: number;
509
+ snippet: string;
510
+ }>;
511
+ }
512
+
513
+ rebuild_fts() {
514
+ this.db.run(
515
+ `INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`,
516
+ );
517
+ }
518
+
519
+ get_sessions(
520
+ options: { limit?: number; project?: string } = {},
521
+ ): Array<{
522
+ id: string;
523
+ project_path: string;
524
+ first_timestamp: number;
525
+ last_timestamp: number;
526
+ message_count: number;
527
+ total_tokens: number;
528
+ duration_mins: number;
529
+ }> {
530
+ const limit = options.limit ?? 10;
531
+ let query = `
532
+ SELECT
533
+ s.id,
534
+ s.project_path,
535
+ s.first_timestamp,
536
+ s.last_timestamp,
537
+ COUNT(m.uuid) as message_count,
538
+ COALESCE(SUM(m.input_tokens + m.output_tokens), 0) as total_tokens,
539
+ CAST((s.last_timestamp - s.first_timestamp) / 60000.0 AS INTEGER) as duration_mins
540
+ FROM sessions s
541
+ LEFT JOIN messages m ON m.session_id = s.id
542
+ `;
543
+ const params: (string | number)[] = [];
544
+
545
+ if (options.project) {
546
+ query += ` WHERE s.project_path LIKE ?`;
547
+ params.push(`%${options.project}%`);
548
+ }
549
+
550
+ query += ` GROUP BY s.id ORDER BY s.last_timestamp DESC LIMIT ?`;
551
+ params.push(limit);
552
+
553
+ return this.db.prepare(query).all(...params) as Array<{
554
+ id: string;
555
+ project_path: string;
556
+ first_timestamp: number;
557
+ last_timestamp: number;
558
+ message_count: number;
559
+ total_tokens: number;
560
+ duration_mins: number;
561
+ }>;
562
+ }
563
+
564
+ get_tool_stats(
565
+ options: { limit?: number; project?: string } = {},
566
+ ): Array<{
567
+ tool_name: string;
568
+ count: number;
569
+ percentage: number;
570
+ }> {
571
+ const limit = options.limit ?? 10;
572
+ let query = `
573
+ SELECT
574
+ tc.tool_name,
575
+ COUNT(*) as count
576
+ FROM tool_calls tc
577
+ `;
578
+ const params: (string | number)[] = [];
579
+
580
+ if (options.project) {
581
+ query += `
582
+ JOIN sessions s ON s.id = tc.session_id
583
+ WHERE s.project_path LIKE ?
584
+ `;
585
+ params.push(`%${options.project}%`);
586
+ }
587
+
588
+ query += `
589
+ GROUP BY tc.tool_name
590
+ ORDER BY count DESC
591
+ LIMIT ?
592
+ `;
593
+ params.push(limit);
594
+
595
+ const rows = this.db.prepare(query).all(...params) as Array<{
596
+ tool_name: string;
597
+ count: number;
598
+ }>;
599
+
600
+ const total = rows.reduce((sum, r) => sum + r.count, 0);
601
+
602
+ return rows.map((r) => ({
603
+ tool_name: r.tool_name,
604
+ count: r.count,
605
+ percentage: total > 0 ? (r.count / total) * 100 : 0,
606
+ }));
607
+ }
608
+
447
609
  close() {
448
610
  this.db.close();
449
611
  }