@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 +15 -10
- package/dist/src/cli/commands/consume.d.ts +7 -0
- package/dist/src/cli/commands/consume.js +60 -0
- package/dist/src/cli/index.js +11 -0
- package/dist/src/services/db/repositories/scanItemsRepo.d.ts +5 -0
- package/dist/src/services/db/repositories/scanItemsRepo.js +63 -5
- package/dist/src/services/db/sqlite.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
|
|
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
|
[](https://github.com/telepat-io/snoopy/actions/workflows/ci.yml)
|
|
10
6
|
[](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.
|
|
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
|
-
|
|
176
|
+
10. Show recent errors for one job:
|
|
173
177
|
|
|
174
178
|
```bash
|
|
175
179
|
snoopy errors <jobRef>
|
|
176
180
|
```
|
|
177
181
|
|
|
178
|
-
|
|
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,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
|
package/dist/src/cli/index.js
CHANGED
|
@@ -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
|