@telepat/snoopy 0.1.11 → 0.1.12

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
@@ -1,10 +1,6 @@
1
- ```text
2
- ┌─┐┌┐┌┌─┐┌─┐┌─┐┬
3
- └─┐││││ ││ │├─┘└┬┘
4
- └─┘┘└┘└─┘└─┘┴ ┴
5
- ```
6
-
7
- # Monitor Reddit Conversations With AI
1
+ <p align="center"><img src="./snoopy-logo.webp" width="128" alt="Snoopy"></p>
2
+ <h1 align="center">Snoopy</h1>
3
+ <p align="center"><em>Sniff out the conversations that matter.</em></p>
8
4
 
9
5
  [![Build](https://img.shields.io/github/actions/workflow/status/telepat-io/snoopy/ci.yml?branch=main&label=build)](https://github.com/telepat-io/snoopy/actions/workflows/ci.yml)
10
6
  [![Coverage](https://codecov.io/gh/telepat-io/snoopy/graph/badge.svg)](https://codecov.io/gh/telepat-io/snoopy)
@@ -159,7 +155,15 @@ snoopy export
159
155
  snoopy export <jobRef> --json --last-run
160
156
  ```
161
157
 
162
- 8. Inspect one run's detailed log output:
158
+ 8. Consume unconsumed qualified results (read-once, most recent first):
159
+
160
+ ```bash
161
+ snoopy consume
162
+ snoopy consume <jobRef> --limit 10
163
+ snoopy consume <jobRef> --json --dry-run
164
+ ```
165
+
166
+ 9. Inspect one run's detailed log output:
163
167
 
164
168
  ```bash
165
169
  snoopy logs
@@ -169,13 +173,13 @@ snoopy logs <runId> --raw
169
173
 
170
174
  When `runId` is omitted for `logs`, Snoopy first prompts for a job, then prompts for a run from that job (up/down arrows + Enter).
171
175
 
172
- 9. Show recent errors for one job:
176
+ 10. Show recent errors for one job:
173
177
 
174
178
  ```bash
175
179
  snoopy errors <jobRef>
176
180
  ```
177
181
 
178
- 10. Enable daemon mode:
182
+ 11. Enable daemon mode:
179
183
 
180
184
  ```bash
181
185
  snoopy daemon start
@@ -191,6 +195,7 @@ snoopy daemon reload
191
195
  - `analytics [jobRef] --days <N>`
192
196
  - `results [jobRef]`
193
197
  - `export [jobRef] --csv|--json [--last-run]`
198
+ - `consume [jobRef] [--limit <N>] [--json] [--dry-run]`
194
199
  - `logs [runId]`
195
200
  - `errors [jobRef] --hours <N>`
196
201
  - `start [jobRef]` / `stop [jobRef]`
@@ -0,0 +1,7 @@
1
+ interface ConsumeCommandOptions {
2
+ limit?: number;
3
+ json?: boolean;
4
+ dryRun?: boolean;
5
+ }
6
+ export declare function consumeResults(jobRef?: string, options?: ConsumeCommandOptions): void;
7
+ export {};
@@ -0,0 +1,60 @@
1
+ import { JobsRepository } from '../../services/db/repositories/jobsRepo.js';
2
+ import { ScanItemsRepository } from '../../services/db/repositories/scanItemsRepo.js';
3
+ import { printCommandScreen, printError, printInfo, printKeyValue, printMuted, printSuccess, printWarning } from '../ui/consoleUi.js';
4
+ function truncateContent(content, maxLength) {
5
+ if (content.length <= maxLength) {
6
+ return content;
7
+ }
8
+ return `${content.slice(0, maxLength)}...`;
9
+ }
10
+ function renderResultRow(row, index) {
11
+ printInfo(`#${index + 1}`);
12
+ printKeyValue('Job', row.jobId);
13
+ printKeyValue('Author', row.author);
14
+ if (row.title) {
15
+ printKeyValue('Title', row.title);
16
+ }
17
+ printKeyValue('URL', row.url);
18
+ printKeyValue('Date', row.redditPostedAt);
19
+ if (row.qualificationReason) {
20
+ printKeyValue('Reason', truncateContent(row.qualificationReason, 120));
21
+ }
22
+ printMuted(truncateContent(row.body, 200));
23
+ console.log('');
24
+ }
25
+ export function consumeResults(jobRef, options = {}) {
26
+ printCommandScreen('Consume results', 'List and consume unconsumed qualified results');
27
+ const jobsRepo = new JobsRepository();
28
+ const scanItemsRepo = new ScanItemsRepository();
29
+ let jobId;
30
+ if (jobRef) {
31
+ const job = jobsRepo.getByRef(jobRef);
32
+ if (!job) {
33
+ printError(`Job not found: ${jobRef}`);
34
+ return;
35
+ }
36
+ jobId = job.id;
37
+ }
38
+ const rows = scanItemsRepo.listUnconsumedQualified(jobId, options.limit);
39
+ if (rows.length === 0) {
40
+ if (options.json) {
41
+ console.log(JSON.stringify([], null, 2));
42
+ return;
43
+ }
44
+ printWarning('No unconsumed qualified results found.');
45
+ return;
46
+ }
47
+ if (options.json) {
48
+ console.log(JSON.stringify(rows, null, 2));
49
+ }
50
+ else {
51
+ rows.forEach((row, index) => renderResultRow(row, index));
52
+ }
53
+ if (options.dryRun) {
54
+ printSuccess(`Found ${rows.length} result(s) (dry run — not consumed).`);
55
+ return;
56
+ }
57
+ const consumedCount = scanItemsRepo.markConsumed(rows.map((r) => r.id));
58
+ printSuccess(`Consumed ${consumedCount} result(s).`);
59
+ }
60
+ //# sourceMappingURL=consume.js.map
@@ -7,6 +7,7 @@ import { daemonReload, daemonRun, daemonStart, daemonStatus, daemonStop } from '
7
7
  import { showRunLogs } from './commands/logs.js';
8
8
  import { showJobErrors } from './commands/errors.js';
9
9
  import { exportCsv } from './commands/export.js';
10
+ import { consumeResults } from './commands/consume.js';
10
11
  import { showAnalytics } from './commands/analytics.js';
11
12
  import { showResults } from './commands/results.js';
12
13
  import { disableStartupCommand, enableStartupCommand, installStartupCommand, startupStatusCommand, uninstallStartupCommand } from './commands/startup.js';
@@ -142,6 +143,16 @@ program
142
143
  .action((jobRef, options) => {
143
144
  exportCsv(jobRef, options);
144
145
  });
146
+ program
147
+ .command('consume')
148
+ .argument('[jobRef]', 'Optional job ID or slug')
149
+ .description('List and mark unconsumed qualified results')
150
+ .option('--limit <count>', 'Maximum number of results to consume', parsePositiveInteger)
151
+ .option('--json', 'Output raw JSON array to stdout')
152
+ .option('--dry-run', 'Preview results without marking them consumed')
153
+ .action((jobRef, options) => {
154
+ consumeResults(jobRef, options);
155
+ });
145
156
  program.parseAsync(process.argv).catch((error) => {
146
157
  console.error(`Error: ${String(error)}`);
147
158
  process.exit(1);
@@ -15,6 +15,7 @@ export interface NewScanItem {
15
15
  viewed?: boolean;
16
16
  validated?: boolean;
17
17
  processed?: boolean;
18
+ consumed?: boolean;
18
19
  promptTokens?: number;
19
20
  completionTokens?: number;
20
21
  estimatedCostUsd?: number | null;
@@ -46,6 +47,7 @@ export interface QualifiedScanItemRow {
46
47
  viewed: boolean;
47
48
  validated: boolean;
48
49
  processed: boolean;
50
+ consumed: boolean;
49
51
  qualificationReason: string | null;
50
52
  createdAt: string;
51
53
  }
@@ -66,6 +68,7 @@ export interface ScanItemRow {
66
68
  viewed: boolean;
67
69
  validated: boolean;
68
70
  processed: boolean;
71
+ consumed: boolean;
69
72
  qualificationReason: string | null;
70
73
  promptTokens: number;
71
74
  completionTokens: number;
@@ -113,6 +116,8 @@ export declare class ScanItemsRepository {
113
116
  listAnalyticsBySubreddit(filter: AnalyticsFilter): AnalyticsBySubredditRow[];
114
117
  listAnalyticsByJob(days: number): AnalyticsByJobRow[];
115
118
  existsComment(jobId: string, postId: string, commentId: string): boolean;
119
+ listUnconsumedQualified(jobId?: string, limit?: number): QualifiedScanItemRow[];
120
+ markConsumed(ids: string[]): number;
116
121
  createWithStatus(item: NewScanItem): CreateScanItemResult;
117
122
  create(item: NewScanItem): string;
118
123
  listCommentThreadNodes(scanItemId: string): CommentThreadNodeRow[];
@@ -31,7 +31,8 @@ export class ScanItemsRepository {
31
31
  qualified: row.qualified === 1,
32
32
  viewed: row.viewed === 1,
33
33
  validated: row.validated === 1,
34
- processed: row.processed === 1
34
+ processed: row.processed === 1,
35
+ consumed: row.consumed === 1
35
36
  }));
36
37
  }
37
38
  buildFilterClause(alias, filter) {
@@ -70,6 +71,7 @@ export class ScanItemsRepository {
70
71
  viewed,
71
72
  validated,
72
73
  processed,
74
+ consumed,
73
75
  qualification_reason as qualificationReason,
74
76
  created_at as createdAt
75
77
  FROM scan_items
@@ -82,7 +84,8 @@ export class ScanItemsRepository {
82
84
  ...row,
83
85
  viewed: row.viewed === 1,
84
86
  validated: row.validated === 1,
85
- processed: row.processed === 1
87
+ processed: row.processed === 1,
88
+ consumed: row.consumed === 1
86
89
  }));
87
90
  }
88
91
  listQualifiedByJobRun(jobId, runId, limit = 100) {
@@ -100,6 +103,7 @@ export class ScanItemsRepository {
100
103
  viewed,
101
104
  validated,
102
105
  processed,
106
+ consumed,
103
107
  qualification_reason as qualificationReason,
104
108
  created_at as createdAt
105
109
  FROM scan_items
@@ -113,7 +117,8 @@ export class ScanItemsRepository {
113
117
  ...row,
114
118
  viewed: row.viewed === 1,
115
119
  validated: row.validated === 1,
116
- processed: row.processed === 1
120
+ processed: row.processed === 1,
121
+ consumed: row.consumed === 1
117
122
  }));
118
123
  }
119
124
  listByJob(jobId) {
@@ -135,6 +140,7 @@ export class ScanItemsRepository {
135
140
  viewed,
136
141
  validated,
137
142
  processed,
143
+ consumed,
138
144
  qualification_reason as qualificationReason,
139
145
  prompt_tokens as promptTokens,
140
146
  completion_tokens as completionTokens,
@@ -175,6 +181,7 @@ export class ScanItemsRepository {
175
181
  viewed,
176
182
  validated,
177
183
  processed,
184
+ consumed,
178
185
  qualification_reason as qualificationReason,
179
186
  prompt_tokens as promptTokens,
180
187
  completion_tokens as completionTokens,
@@ -283,6 +290,56 @@ export class ScanItemsRepository {
283
290
  .get(jobId, postId, commentId);
284
291
  return Boolean(row);
285
292
  }
293
+ listUnconsumedQualified(jobId, limit) {
294
+ const hasJobId = Boolean(jobId);
295
+ const hasLimit = limit !== undefined && limit !== null;
296
+ const boundedLimit = hasLimit ? Math.max(1, Math.floor(limit)) : undefined;
297
+ let query = `SELECT
298
+ id,
299
+ job_id as jobId,
300
+ run_id as runId,
301
+ author,
302
+ title,
303
+ body,
304
+ url,
305
+ reddit_posted_at as redditPostedAt,
306
+ viewed,
307
+ validated,
308
+ processed,
309
+ consumed,
310
+ qualification_reason as qualificationReason,
311
+ created_at as createdAt
312
+ FROM scan_items
313
+ WHERE qualified = 1 AND consumed = 0`;
314
+ const params = [];
315
+ if (hasJobId) {
316
+ query += ' AND job_id = ?';
317
+ params.push(jobId);
318
+ }
319
+ query += ' ORDER BY datetime(created_at) DESC, id DESC';
320
+ if (boundedLimit !== undefined) {
321
+ query += ' LIMIT ?';
322
+ params.push(boundedLimit);
323
+ }
324
+ const rows = this.db.prepare(query).all(...params);
325
+ return rows.map((row) => ({
326
+ ...row,
327
+ viewed: row.viewed === 1,
328
+ validated: row.validated === 1,
329
+ processed: row.processed === 1,
330
+ consumed: row.consumed === 1
331
+ }));
332
+ }
333
+ markConsumed(ids) {
334
+ if (ids.length === 0) {
335
+ return 0;
336
+ }
337
+ const placeholders = ids.map(() => '?').join(',');
338
+ const result = this.db
339
+ .prepare(`UPDATE scan_items SET consumed = 1 WHERE id IN (${placeholders})`)
340
+ .run(...ids);
341
+ return Number(result.changes);
342
+ }
286
343
  createWithStatus(item) {
287
344
  const id = crypto.randomUUID();
288
345
  const createInTransaction = this.db.transaction((newId, newItem) => {
@@ -304,13 +361,14 @@ export class ScanItemsRepository {
304
361
  viewed,
305
362
  validated,
306
363
  processed,
364
+ consumed,
307
365
  prompt_tokens,
308
366
  completion_tokens,
309
367
  estimated_cost_usd,
310
368
  qualification_reason,
311
369
  created_at
312
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
313
- .run(newId, newItem.jobId, newItem.runId, newItem.type, newItem.redditPostId, newItem.redditCommentId, newItem.subreddit, newItem.author, newItem.title, newItem.body, newItem.url, newItem.redditPostedAt, newItem.qualified ? 1 : 0, newItem.viewed ? 1 : 0, newItem.validated ? 1 : 0, newItem.processed ? 1 : 0, newItem.promptTokens ?? 0, newItem.completionTokens ?? 0, newItem.estimatedCostUsd ?? null, newItem.qualificationReason);
370
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
371
+ .run(newId, newItem.jobId, newItem.runId, newItem.type, newItem.redditPostId, newItem.redditCommentId, newItem.subreddit, newItem.author, newItem.title, newItem.body, newItem.url, newItem.redditPostedAt, newItem.qualified ? 1 : 0, newItem.viewed ? 1 : 0, newItem.validated ? 1 : 0, newItem.processed ? 1 : 0, newItem.consumed ? 1 : 0, newItem.promptTokens ?? 0, newItem.completionTokens ?? 0, newItem.estimatedCostUsd ?? null, newItem.qualificationReason);
314
372
  if (insertResult.changes === 0) {
315
373
  const existingId = this.findExistingScanItemId(newItem.jobId, newItem.redditPostId, newItem.redditCommentId);
316
374
  return {
@@ -68,6 +68,7 @@ export function getDb() {
68
68
  viewed INTEGER NOT NULL DEFAULT 0,
69
69
  validated INTEGER NOT NULL DEFAULT 0,
70
70
  processed INTEGER NOT NULL DEFAULT 0,
71
+ consumed INTEGER NOT NULL DEFAULT 0,
71
72
  prompt_tokens INTEGER NOT NULL DEFAULT 0,
72
73
  completion_tokens INTEGER NOT NULL DEFAULT 0,
73
74
  estimated_cost_usd REAL,
@@ -206,6 +207,13 @@ export function getDb() {
206
207
  catch {
207
208
  // Column already exists.
208
209
  }
210
+ try {
211
+ db.exec('ALTER TABLE scan_items ADD COLUMN consumed INTEGER NOT NULL DEFAULT 0');
212
+ }
213
+ catch {
214
+ // Column already exists.
215
+ }
216
+ db.exec('CREATE INDEX IF NOT EXISTS idx_scan_items_consumed ON scan_items(job_id, qualified, consumed, created_at DESC)');
209
217
  return db;
210
218
  }
211
219
  //# sourceMappingURL=sqlite.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepat/snoopy",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Snoopy CLI for Reddit conversation monitoring jobs.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",