@telepat/snoopy 0.1.11 → 0.1.13

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,16 +1,18 @@
1
- ```text
2
- ┌─┐┌┐┌┌─┐┌─┐┌─┐┬
3
- └─┐││││ ││ │├─┘└┬┘
4
- └─┘┘└┘└─┘└─┘┴ ┴
5
- ```
6
-
7
- # Monitor Reddit Conversations With AI
8
-
9
- [![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
- [![Coverage](https://codecov.io/gh/telepat-io/snoopy/graph/badge.svg)](https://codecov.io/gh/telepat-io/snoopy)
11
- [![npm](https://img.shields.io/npm/v/@telepat/snoopy)](https://www.npmjs.com/package/@telepat/snoopy)
12
-
13
- 📖 [Full documentation](https://docs.telepat.io/ideon/)
1
+ <p align="center"><img src="./snoopy-logo.webp" width="128" alt="Snoopy"></p>
2
+ <h1 align="center">Snoopy</h1>
3
+ <hr>
4
+ <p align="center"><em>Sniff out the conversations that matter.</em></p>
5
+
6
+ <p align="center">
7
+ <a href="https://docs.telepat.io/snoopy">📖 Docs</a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/telepat-io/snoopy/actions/workflows/ci.yml"><img src="https://github.com/telepat-io/snoopy/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build"></a>
12
+ <a href="https://codecov.io/gh/telepat-io/snoopy"><img src="https://codecov.io/gh/telepat-io/snoopy/graph/badge.svg" alt="Codecov"></a>
13
+ <a href="https://www.npmjs.com/package/@telepat/snoopy"><img src="https://img.shields.io/npm/v/@telepat/snoopy" alt="npm"></a>
14
+ <a href="https://github.com/telepat-io/snoopy/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-yellow.svg" alt="License"></a>
15
+ </p>
14
16
 
15
17
  Snoopy helps you monitor Reddit for high-intent conversations that match your business goals.
16
18
 
@@ -159,7 +161,15 @@ snoopy export
159
161
  snoopy export <jobRef> --json --last-run
160
162
  ```
161
163
 
162
- 8. Inspect one run's detailed log output:
164
+ 8. Consume unconsumed qualified results (read-once, most recent first):
165
+
166
+ ```bash
167
+ snoopy consume
168
+ snoopy consume <jobRef> --limit 10
169
+ snoopy consume <jobRef> --json --dry-run
170
+ ```
171
+
172
+ 9. Inspect one run's detailed log output:
163
173
 
164
174
  ```bash
165
175
  snoopy logs
@@ -169,13 +179,13 @@ snoopy logs <runId> --raw
169
179
 
170
180
  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
181
 
172
- 9. Show recent errors for one job:
182
+ 10. Show recent errors for one job:
173
183
 
174
184
  ```bash
175
185
  snoopy errors <jobRef>
176
186
  ```
177
187
 
178
- 10. Enable daemon mode:
188
+ 11. Enable daemon mode:
179
189
 
180
190
  ```bash
181
191
  snoopy daemon start
@@ -191,6 +201,7 @@ snoopy daemon reload
191
201
  - `analytics [jobRef] --days <N>`
192
202
  - `results [jobRef]`
193
203
  - `export [jobRef] --csv|--json [--last-run]`
204
+ - `consume [jobRef] [--limit <N>] [--json] [--dry-run]`
194
205
  - `logs [runId]`
195
206
  - `errors [jobRef] --hours <N>`
196
207
  - `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
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { getDb } from '../../services/db/sqlite.js';
3
+ import { getAppliedMigrations, getPendingMigrations } from '../../services/db/migrations/runner.js';
3
4
  import { JobsRepository } from '../../services/db/repositories/jobsRepo.js';
4
5
  import { RunsRepository } from '../../services/db/repositories/runsRepo.js';
5
6
  import { extractErrorEntries, readRunLog } from '../../services/logging/logReader.js';
@@ -37,8 +38,9 @@ export async function runDoctor() {
37
38
  const paths = ensureAppDirs();
38
39
  let dbOk = false;
39
40
  let dbDetails = `DB file: ${paths.dbPath}`;
41
+ let db = null;
40
42
  try {
41
- const db = getDb();
43
+ db = getDb();
42
44
  db.prepare('SELECT 1').get();
43
45
  dbOk = true;
44
46
  dbDetails = `DB reachable at ${paths.dbPath}`;
@@ -56,8 +58,17 @@ export async function runDoctor() {
56
58
  const daemon = getDaemonHealth();
57
59
  printKeyValue('Platform', process.platform);
58
60
  printKeyValue('Node', process.version);
59
- if (dbOk) {
61
+ if (dbOk && db) {
60
62
  printSuccess(`Database: ${dbDetails}`);
63
+ const applied = getAppliedMigrations(db);
64
+ const pending = getPendingMigrations(db);
65
+ if (pending.length === 0) {
66
+ printSuccess(`Migrations: ${applied.length} applied, 0 pending`);
67
+ }
68
+ else {
69
+ printWarning(`Migrations: ${applied.length} applied, ${pending.length} pending`);
70
+ pending.forEach((m) => printMuted(` → pending: ${m.id} ${m.name}`));
71
+ }
61
72
  }
62
73
  else {
63
74
  printError(`Database: ${dbDetails}`);
@@ -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);
@@ -0,0 +1,7 @@
1
+ import type Database from 'better-sqlite3';
2
+ declare const _default: {
3
+ id: number;
4
+ name: string;
5
+ up(db: Database.Database): void;
6
+ };
7
+ export default _default;
@@ -0,0 +1,132 @@
1
+ export default {
2
+ id: 1,
3
+ name: 'baseline',
4
+ up(db) {
5
+ db.exec(`
6
+ CREATE TABLE IF NOT EXISTS settings (
7
+ key TEXT PRIMARY KEY,
8
+ value TEXT NOT NULL,
9
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS jobs (
13
+ id TEXT PRIMARY KEY,
14
+ slug TEXT UNIQUE,
15
+ name TEXT NOT NULL UNIQUE,
16
+ description TEXT NOT NULL,
17
+ qualification_prompt TEXT NOT NULL,
18
+ subreddits_json TEXT NOT NULL,
19
+ schedule_cron TEXT NOT NULL DEFAULT '*/30 * * * *',
20
+ enabled INTEGER NOT NULL DEFAULT 1,
21
+ monitor_comments INTEGER NOT NULL DEFAULT 1,
22
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
23
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS job_runs (
27
+ id TEXT PRIMARY KEY,
28
+ job_id TEXT NOT NULL,
29
+ status TEXT NOT NULL,
30
+ message TEXT,
31
+ started_at TEXT,
32
+ finished_at TEXT,
33
+ items_discovered INTEGER NOT NULL DEFAULT 0,
34
+ items_new INTEGER NOT NULL DEFAULT 0,
35
+ items_qualified INTEGER NOT NULL DEFAULT 0,
36
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
37
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
38
+ estimated_cost_usd REAL,
39
+ log_file_path TEXT,
40
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
41
+ FOREIGN KEY (job_id) REFERENCES jobs(id)
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS scan_items (
45
+ id TEXT PRIMARY KEY,
46
+ job_id TEXT NOT NULL,
47
+ run_id TEXT NOT NULL,
48
+ type TEXT NOT NULL CHECK (type IN ('post', 'comment')),
49
+ reddit_post_id TEXT NOT NULL,
50
+ reddit_comment_id TEXT,
51
+ subreddit TEXT NOT NULL,
52
+ author TEXT NOT NULL,
53
+ title TEXT,
54
+ body TEXT NOT NULL,
55
+ url TEXT NOT NULL,
56
+ reddit_posted_at TEXT NOT NULL,
57
+ qualified INTEGER NOT NULL DEFAULT 0,
58
+ viewed INTEGER NOT NULL DEFAULT 0,
59
+ validated INTEGER NOT NULL DEFAULT 0,
60
+ processed INTEGER NOT NULL DEFAULT 0,
61
+ consumed INTEGER NOT NULL DEFAULT 0,
62
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
63
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
64
+ estimated_cost_usd REAL,
65
+ qualification_reason TEXT,
66
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
67
+ FOREIGN KEY (job_id) REFERENCES jobs(id),
68
+ FOREIGN KEY (run_id) REFERENCES job_runs(id)
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS comment_thread_nodes (
72
+ id TEXT PRIMARY KEY,
73
+ scan_item_id TEXT NOT NULL,
74
+ reddit_comment_id TEXT NOT NULL,
75
+ parent_reddit_comment_id TEXT,
76
+ author TEXT NOT NULL,
77
+ body TEXT NOT NULL,
78
+ depth INTEGER NOT NULL,
79
+ is_target INTEGER NOT NULL DEFAULT 0,
80
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
81
+ FOREIGN KEY (scan_item_id) REFERENCES scan_items(id)
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS daemon_state (
85
+ id INTEGER PRIMARY KEY CHECK (id = 1),
86
+ is_running INTEGER NOT NULL,
87
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
88
+ );
89
+ `);
90
+ // Belt-and-suspenders: these columns were historically added via inline
91
+ // ALTER TABLE blocks. For edge-case databases that may be missing them,
92
+ // we safely attempt to add each column and ignore "already exists" errors.
93
+ const safeAddColumn = (table, column, type) => {
94
+ try {
95
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
96
+ }
97
+ catch {
98
+ // Column already exists or table does not exist.
99
+ }
100
+ };
101
+ safeAddColumn('jobs', 'slug', 'TEXT');
102
+ safeAddColumn('jobs', 'monitor_comments', 'INTEGER NOT NULL DEFAULT 1');
103
+ safeAddColumn('job_runs', 'started_at', 'TEXT');
104
+ safeAddColumn('job_runs', 'finished_at', 'TEXT');
105
+ safeAddColumn('job_runs', 'items_discovered', 'INTEGER NOT NULL DEFAULT 0');
106
+ safeAddColumn('job_runs', 'items_new', 'INTEGER NOT NULL DEFAULT 0');
107
+ safeAddColumn('job_runs', 'items_qualified', 'INTEGER NOT NULL DEFAULT 0');
108
+ safeAddColumn('job_runs', 'prompt_tokens', 'INTEGER NOT NULL DEFAULT 0');
109
+ safeAddColumn('job_runs', 'completion_tokens', 'INTEGER NOT NULL DEFAULT 0');
110
+ safeAddColumn('job_runs', 'estimated_cost_usd', 'REAL');
111
+ safeAddColumn('job_runs', 'log_file_path', 'TEXT');
112
+ safeAddColumn('scan_items', 'viewed', 'INTEGER NOT NULL DEFAULT 0');
113
+ safeAddColumn('scan_items', 'validated', 'INTEGER NOT NULL DEFAULT 0');
114
+ safeAddColumn('scan_items', 'processed', 'INTEGER NOT NULL DEFAULT 0');
115
+ safeAddColumn('scan_items', 'consumed', 'INTEGER NOT NULL DEFAULT 0');
116
+ safeAddColumn('scan_items', 'prompt_tokens', 'INTEGER NOT NULL DEFAULT 0');
117
+ safeAddColumn('scan_items', 'completion_tokens', 'INTEGER NOT NULL DEFAULT 0');
118
+ safeAddColumn('scan_items', 'estimated_cost_usd', 'REAL');
119
+ db.exec(`
120
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_slug ON jobs(slug);
121
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_job_runs_active_job ON job_runs(job_id) WHERE status = 'running';
122
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_items_dedup ON scan_items(job_id, reddit_post_id, COALESCE(reddit_comment_id, ''));
123
+ CREATE INDEX IF NOT EXISTS idx_scan_items_job_qualified_posted ON scan_items(job_id, qualified, reddit_posted_at DESC, created_at DESC);
124
+ CREATE INDEX IF NOT EXISTS idx_scan_items_job_created ON scan_items(job_id, created_at DESC);
125
+ CREATE INDEX IF NOT EXISTS idx_scan_items_created ON scan_items(created_at DESC);
126
+ CREATE INDEX IF NOT EXISTS idx_comment_thread_nodes_scan_item_depth ON comment_thread_nodes(scan_item_id, depth ASC);
127
+ CREATE INDEX IF NOT EXISTS idx_comment_thread_nodes_parent ON comment_thread_nodes(parent_reddit_comment_id);
128
+ CREATE INDEX IF NOT EXISTS idx_scan_items_consumed ON scan_items(job_id, qualified, consumed, created_at DESC);
129
+ `);
130
+ }
131
+ };
132
+ //# sourceMappingURL=001_baseline.js.map
@@ -0,0 +1,7 @@
1
+ import type Database from 'better-sqlite3';
2
+ export interface Migration {
3
+ id: number;
4
+ name: string;
5
+ up: (db: Database.Database) => void;
6
+ }
7
+ export declare const migrations: Migration[];
@@ -0,0 +1,3 @@
1
+ import baseline from './001_baseline.js';
2
+ export const migrations = [baseline];
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ import type Database from 'better-sqlite3';
2
+ import { type Migration } from './index.js';
3
+ export declare function runMigrations(db: Database.Database): void;
4
+ export declare function getAppliedMigrations(db: Database.Database): Array<{
5
+ id: number;
6
+ name: string;
7
+ appliedAt: string;
8
+ }>;
9
+ export declare function getPendingMigrations(db: Database.Database): Migration[];
@@ -0,0 +1,43 @@
1
+ import { migrations } from './index.js';
2
+ export function runMigrations(db) {
3
+ db.exec(`
4
+ CREATE TABLE IF NOT EXISTS migrations (
5
+ id INTEGER PRIMARY KEY,
6
+ name TEXT NOT NULL,
7
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
8
+ )
9
+ `);
10
+ const appliedRows = db.prepare('SELECT id FROM migrations').all();
11
+ const appliedIds = new Set(appliedRows.map((r) => r.id));
12
+ for (const migration of migrations) {
13
+ if (appliedIds.has(migration.id)) {
14
+ continue;
15
+ }
16
+ const runInTransaction = db.transaction((mig) => {
17
+ mig.up(db);
18
+ db.prepare('INSERT INTO migrations (id, name) VALUES (?, ?)').run(mig.id, mig.name);
19
+ });
20
+ runInTransaction(migration);
21
+ }
22
+ }
23
+ export function getAppliedMigrations(db) {
24
+ try {
25
+ return db
26
+ .prepare('SELECT id, name, applied_at as appliedAt FROM migrations ORDER BY id ASC')
27
+ .all();
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ export function getPendingMigrations(db) {
34
+ try {
35
+ const appliedRows = db.prepare('SELECT id FROM migrations').all();
36
+ const appliedIds = new Set(appliedRows.map((r) => r.id));
37
+ return migrations.filter((m) => !appliedIds.has(m.id));
38
+ }
39
+ catch {
40
+ return [...migrations];
41
+ }
42
+ }
43
+ //# sourceMappingURL=runner.js.map
@@ -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 {
@@ -1,5 +1,6 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { ensureAppDirs } from '../../utils/paths.js';
3
+ import { runMigrations } from './migrations/runner.js';
3
4
  let db = null;
4
5
  export function getDb() {
5
6
  if (db) {
@@ -13,199 +14,7 @@ export function getDb() {
13
14
  catch {
14
15
  // In rare concurrent startup cases (for example tests), DB may already be locked.
15
16
  }
16
- db.exec(`
17
- CREATE TABLE IF NOT EXISTS settings (
18
- key TEXT PRIMARY KEY,
19
- value TEXT NOT NULL,
20
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21
- );
22
-
23
- CREATE TABLE IF NOT EXISTS jobs (
24
- id TEXT PRIMARY KEY,
25
- slug TEXT UNIQUE,
26
- name TEXT NOT NULL UNIQUE,
27
- description TEXT NOT NULL,
28
- qualification_prompt TEXT NOT NULL,
29
- subreddits_json TEXT NOT NULL,
30
- schedule_cron TEXT NOT NULL DEFAULT '*/30 * * * *',
31
- enabled INTEGER NOT NULL DEFAULT 1,
32
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
33
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
34
- );
35
-
36
- CREATE TABLE IF NOT EXISTS job_runs (
37
- id TEXT PRIMARY KEY,
38
- job_id TEXT NOT NULL,
39
- status TEXT NOT NULL,
40
- message TEXT,
41
- started_at TEXT,
42
- finished_at TEXT,
43
- items_discovered INTEGER NOT NULL DEFAULT 0,
44
- items_new INTEGER NOT NULL DEFAULT 0,
45
- items_qualified INTEGER NOT NULL DEFAULT 0,
46
- prompt_tokens INTEGER NOT NULL DEFAULT 0,
47
- completion_tokens INTEGER NOT NULL DEFAULT 0,
48
- estimated_cost_usd REAL,
49
- log_file_path TEXT,
50
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
51
- FOREIGN KEY (job_id) REFERENCES jobs(id)
52
- );
53
-
54
- CREATE TABLE IF NOT EXISTS scan_items (
55
- id TEXT PRIMARY KEY,
56
- job_id TEXT NOT NULL,
57
- run_id TEXT NOT NULL,
58
- type TEXT NOT NULL CHECK (type IN ('post', 'comment')),
59
- reddit_post_id TEXT NOT NULL,
60
- reddit_comment_id TEXT,
61
- subreddit TEXT NOT NULL,
62
- author TEXT NOT NULL,
63
- title TEXT,
64
- body TEXT NOT NULL,
65
- url TEXT NOT NULL,
66
- reddit_posted_at TEXT NOT NULL,
67
- qualified INTEGER NOT NULL DEFAULT 0,
68
- viewed INTEGER NOT NULL DEFAULT 0,
69
- validated INTEGER NOT NULL DEFAULT 0,
70
- processed INTEGER NOT NULL DEFAULT 0,
71
- prompt_tokens INTEGER NOT NULL DEFAULT 0,
72
- completion_tokens INTEGER NOT NULL DEFAULT 0,
73
- estimated_cost_usd REAL,
74
- qualification_reason TEXT,
75
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
76
- FOREIGN KEY (job_id) REFERENCES jobs(id),
77
- FOREIGN KEY (run_id) REFERENCES job_runs(id)
78
- );
79
-
80
- CREATE TABLE IF NOT EXISTS comment_thread_nodes (
81
- id TEXT PRIMARY KEY,
82
- scan_item_id TEXT NOT NULL,
83
- reddit_comment_id TEXT NOT NULL,
84
- parent_reddit_comment_id TEXT,
85
- author TEXT NOT NULL,
86
- body TEXT NOT NULL,
87
- depth INTEGER NOT NULL,
88
- is_target INTEGER NOT NULL DEFAULT 0,
89
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
90
- FOREIGN KEY (scan_item_id) REFERENCES scan_items(id)
91
- );
92
-
93
- CREATE TABLE IF NOT EXISTS daemon_state (
94
- id INTEGER PRIMARY KEY CHECK (id = 1),
95
- is_running INTEGER NOT NULL,
96
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
97
- );
98
- `);
99
- try {
100
- db.exec('ALTER TABLE jobs ADD COLUMN slug TEXT');
101
- }
102
- catch {
103
- // Column already exists.
104
- }
105
- try {
106
- db.exec('ALTER TABLE jobs ADD COLUMN monitor_comments INTEGER NOT NULL DEFAULT 1');
107
- }
108
- catch {
109
- // Column already exists.
110
- }
111
- db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_slug ON jobs(slug)');
112
- db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_job_runs_active_job ON job_runs(job_id) WHERE status = 'running'");
113
- db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_items_dedup ON scan_items(job_id, reddit_post_id, COALESCE(reddit_comment_id, ''))");
114
- db.exec('CREATE INDEX IF NOT EXISTS idx_scan_items_job_qualified_posted ON scan_items(job_id, qualified, reddit_posted_at DESC, created_at DESC)');
115
- db.exec('CREATE INDEX IF NOT EXISTS idx_scan_items_job_created ON scan_items(job_id, created_at DESC)');
116
- db.exec('CREATE INDEX IF NOT EXISTS idx_scan_items_created ON scan_items(created_at DESC)');
117
- db.exec('CREATE INDEX IF NOT EXISTS idx_comment_thread_nodes_scan_item_depth ON comment_thread_nodes(scan_item_id, depth ASC)');
118
- db.exec('CREATE INDEX IF NOT EXISTS idx_comment_thread_nodes_parent ON comment_thread_nodes(parent_reddit_comment_id)');
119
- try {
120
- db.exec('ALTER TABLE job_runs ADD COLUMN started_at TEXT');
121
- }
122
- catch {
123
- // Column already exists.
124
- }
125
- try {
126
- db.exec('ALTER TABLE job_runs ADD COLUMN finished_at TEXT');
127
- }
128
- catch {
129
- // Column already exists.
130
- }
131
- try {
132
- db.exec('ALTER TABLE job_runs ADD COLUMN items_discovered INTEGER NOT NULL DEFAULT 0');
133
- }
134
- catch {
135
- // Column already exists.
136
- }
137
- try {
138
- db.exec('ALTER TABLE job_runs ADD COLUMN items_new INTEGER NOT NULL DEFAULT 0');
139
- }
140
- catch {
141
- // Column already exists.
142
- }
143
- try {
144
- db.exec('ALTER TABLE job_runs ADD COLUMN items_qualified INTEGER NOT NULL DEFAULT 0');
145
- }
146
- catch {
147
- // Column already exists.
148
- }
149
- try {
150
- db.exec('ALTER TABLE job_runs ADD COLUMN prompt_tokens INTEGER NOT NULL DEFAULT 0');
151
- }
152
- catch {
153
- // Column already exists.
154
- }
155
- try {
156
- db.exec('ALTER TABLE job_runs ADD COLUMN completion_tokens INTEGER NOT NULL DEFAULT 0');
157
- }
158
- catch {
159
- // Column already exists.
160
- }
161
- try {
162
- db.exec('ALTER TABLE job_runs ADD COLUMN estimated_cost_usd REAL');
163
- }
164
- catch {
165
- // Column already exists.
166
- }
167
- try {
168
- db.exec('ALTER TABLE job_runs ADD COLUMN log_file_path TEXT');
169
- }
170
- catch {
171
- // Column already exists.
172
- }
173
- try {
174
- db.exec('ALTER TABLE scan_items ADD COLUMN viewed INTEGER NOT NULL DEFAULT 0');
175
- }
176
- catch {
177
- // Column already exists.
178
- }
179
- try {
180
- db.exec('ALTER TABLE scan_items ADD COLUMN validated INTEGER NOT NULL DEFAULT 0');
181
- }
182
- catch {
183
- // Column already exists.
184
- }
185
- try {
186
- db.exec('ALTER TABLE scan_items ADD COLUMN processed INTEGER NOT NULL DEFAULT 0');
187
- }
188
- catch {
189
- // Column already exists.
190
- }
191
- try {
192
- db.exec('ALTER TABLE scan_items ADD COLUMN prompt_tokens INTEGER NOT NULL DEFAULT 0');
193
- }
194
- catch {
195
- // Column already exists.
196
- }
197
- try {
198
- db.exec('ALTER TABLE scan_items ADD COLUMN completion_tokens INTEGER NOT NULL DEFAULT 0');
199
- }
200
- catch {
201
- // Column already exists.
202
- }
203
- try {
204
- db.exec('ALTER TABLE scan_items ADD COLUMN estimated_cost_usd REAL');
205
- }
206
- catch {
207
- // Column already exists.
208
- }
17
+ runMigrations(db);
209
18
  return db;
210
19
  }
211
20
  //# sourceMappingURL=sqlite.js.map
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  export function getAppPaths() {
5
- const rootDir = process.env.SNOOPY_ROOT_DIR || path.join(os.homedir(), '.snoopy');
5
+ const rootDir = process.env.SNOOPY_E2E_ROOT_DIR || process.env.SNOOPY_ROOT_DIR || path.join(os.homedir(), '.snoopy');
6
6
  return {
7
7
  rootDir,
8
8
  dbPath: path.join(rootDir, 'snoopy.db'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepat/snoopy",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Snoopy CLI for Reddit conversation monitoring jobs.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -25,6 +25,9 @@
25
25
  "docs:serve": "npm --prefix website run serve",
26
26
  "docs:deploy": "npm --prefix website run deploy",
27
27
  "e2e:smoke": "tsx src/scripts/e2eSmoke.ts",
28
+ "e2e:fresh": "tsx tests/e2e/freshInstall.ts",
29
+ "e2e:upgrade": "tsx tests/e2e/upgrade.ts",
30
+ "e2e": "npm run e2e:fresh && npm run e2e:upgrade",
28
31
  "lint": "eslint src tests --ext .ts,.tsx",
29
32
  "test": "jest",
30
33
  "test:coverage": "jest --coverage",