@telepat/snoopy 0.1.14 → 0.1.15

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 (33) hide show
  1. package/README.md +34 -9
  2. package/README.zh-CN.md +34 -9
  3. package/dist/src/cli/commands/feedback.d.ts +18 -0
  4. package/dist/src/cli/commands/feedback.js +276 -0
  5. package/dist/src/cli/commands/prompt.d.ts +6 -0
  6. package/dist/src/cli/commands/prompt.js +92 -0
  7. package/dist/src/cli/commands/promptEditor.d.ts +1 -0
  8. package/dist/src/cli/commands/promptEditor.js +17 -0
  9. package/dist/src/cli/flows/jobAddFlow.js +1 -1
  10. package/dist/src/cli/index.js +49 -1
  11. package/dist/src/mcp/helpers.d.ts +3 -0
  12. package/dist/src/mcp/helpers.js +78 -5
  13. package/dist/src/mcp/server.js +41 -2
  14. package/dist/src/mcp/tools.d.ts +13 -0
  15. package/dist/src/mcp/tools.js +16 -0
  16. package/dist/src/services/db/migrations/002_feedback_fields.d.ts +7 -0
  17. package/dist/src/services/db/migrations/002_feedback_fields.js +22 -0
  18. package/dist/src/services/db/migrations/index.js +2 -1
  19. package/dist/src/services/db/repositories/jobsRepo.d.ts +2 -0
  20. package/dist/src/services/db/repositories/jobsRepo.js +15 -0
  21. package/dist/src/services/db/repositories/scanItemsRepo.d.ts +17 -0
  22. package/dist/src/services/db/repositories/scanItemsRepo.js +197 -2
  23. package/dist/src/services/feedback/consolidationService.d.ts +28 -0
  24. package/dist/src/services/feedback/consolidationService.js +124 -0
  25. package/dist/src/services/openrouter/client.d.ts +23 -0
  26. package/dist/src/services/openrouter/client.js +67 -0
  27. package/dist/src/types/settings.d.ts +1 -1
  28. package/dist/src/types/settings.js +1 -1
  29. package/dist/src/ui/components/MultilinePrompt.d.ts +10 -0
  30. package/dist/src/ui/components/MultilinePrompt.js +87 -0
  31. package/dist/src/ui/components/multilinePromptModel.d.ts +25 -0
  32. package/dist/src/ui/components/multilinePromptModel.js +76 -0
  33. package/package.json +3 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center"><img src="./assets/avatar/snoopy-logo.webp" width="128" alt="Snoopy"></p>
2
2
  <h1 align="center">Snoopy</h1>
3
- <p align="center"><em>Sniff out the conversations that matter.</em></p>
3
+ <p align="center"><em>Monitor online conversations for high-intent signals with AI. Plain language criteria, continuous scanning, zero infrastructure.</em></p>
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://docs.telepat.io/snoopy">📖 Docs</a>
@@ -15,17 +15,23 @@
15
15
  <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>
16
16
  </p>
17
17
 
18
- Snoopy helps you monitor Reddit for high-intent conversations that match your business goals.
18
+ Snoopy monitors online conversations for high-intent signals that match your business goals.
19
19
 
20
- Define what you care about in plain language, let Snoopy create a monitoring job, and continuously scan and qualify posts and comments so you can focus on response and outreach.
20
+ Define what you care about in plain language, let Snoopy create a monitoring job, and continuously scan and qualify conversations so you can focus on response and outreach.
21
21
 
22
- ## What It Solves
22
+ Built for founders, marketers, and sales teams who need to find genuine opportunities in online communities without manually scrolling.
23
23
 
24
- - Turn broad Reddit traffic into a focused stream of opportunities.
25
- - Define qualification logic once, then run continuously.
26
- - Trigger manual runs when you want quick validation.
27
- - Track run analytics (discovered, new, qualified items, token usage, cost estimate).
28
- - Run cross-platform with startup-on-reboot support.
24
+ ## Features
25
+
26
+ - **Plain language job creation** — Describe what you're looking for in plain language. Snoopy builds an AI-assisted monitoring job. No regex, no keyword configs.
27
+ - **AI qualification, not keyword matching** — Conversations are evaluated against your intent. Snoopy understands context — not just pattern matching.
28
+ - **Feedback-driven prompt learning** — Review results, submit valid/invalid feedback, and consolidate updates so your qualification prompt gets smarter over time.
29
+ - **Continuous daemon monitoring** — Set a cron schedule and let Snoopy scan in the background. `snoopy daemon start`
30
+ - **Code-driven efficiency** — Deterministic code handles scraping, scheduling, state management, and SQLite persistence. Tokens only spent on qualification.
31
+ - **Local & private** — SQLite database on your machine. No cloud dependency. Export to CSV or JSON on demand.
32
+ - **Cost-aware analytics** — Token usage, cost estimates, and qualified items per run. `snoopy analytics --days 7`
33
+ - **Agent & CI ready** — MCP server, direct SQLite access, non-interactive mode, machine-readable output.
34
+ - **Cross-platform** — macOS, Linux, Windows. Startup-on-reboot. `snoopy startup install`
29
35
 
30
36
  ## Quick Start
31
37
 
@@ -78,10 +84,29 @@ Snoopy is built for headless automation and agent-driven monitoring:
78
84
 
79
85
  - **Non-interactive CLI** — Most commands support omitting `<jobRef>` to get an interactive picker, but automation can pass refs directly for zero-prompt execution.
80
86
  - **Machine-readable output** — `snoopy export --json --last-run` and `snoopy consume --json` produce structured data for downstream agents.
87
+ - **Feedback loop for continuous quality** — Agents can run `snoopy feedback review --json`, collect human feedback, submit with `snoopy feedback submit`, and finalize with `snoopy feedback consolidate`.
81
88
  - **Direct database access** — SQLite at `~/.snoopy/snoopy.db` (or `$SNOOPY_ROOT_DIR/snoopy.db`) with a documented schema. Agents can insert jobs, query results, and update lifecycle flags directly.
82
89
  - **Environment variables** — `SNOOPY_OPENROUTER_API_KEY`, `SNOOPY_REDDIT_CLIENT_SECRET`, and `SNOOPY_ROOT_DIR` remove all interactive credential prompts.
83
90
  - **Agent docs** — [Agent Operations](https://docs.telepat.io/snoopy/guides/agent-operations) provides a complete runbook for automation, including SQL schema, lifecycle flags, and recommended workflows.
84
91
 
92
+ ## Feedback Workflow
93
+
94
+ Use the feedback commands to improve qualification quality over time:
95
+
96
+ ```bash
97
+ # 1) Review unvalidated qualified results (agent-safe JSON)
98
+ snoopy feedback review --json --limit 10
99
+
100
+ # 2) Submit per-result feedback
101
+ snoopy feedback submit <resultId> --valid
102
+ snoopy feedback submit <resultId> --invalid --reason "Not actually a buying signal"
103
+
104
+ # 3) Consolidate feedback into an updated qualification prompt
105
+ snoopy feedback consolidate
106
+ ```
107
+
108
+ Interactive `snoopy feedback review` sessions also prompt to run consolidation before exiting early.
109
+
85
110
  ## Security And Trust
86
111
 
87
112
  - Secrets are stored in the OS keychain by default (via `keytar`). Falls back to an encrypted file if keychain is unavailable.
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center"><img src="./assets/avatar/snoopy-logo.webp" width="128" alt="Snoopy"></p>
2
2
  <h1 align="center">Snoopy</h1>
3
- <p align="center"><em>嗅å‡ē重čĻįš„å¯šč¯ã€‚</em></p>
3
+ <p align="center"><em>äŊŋᔍ AI į›‘æŽ§åœ¨įēŋå¯šč¯ä¸­įš„éĢ˜æ„å‘äŋĄåˇâ€”—č‡Ēį„ļč¯­č¨€æ ‡å‡†īŧŒæŒįģ­æ‰Ģ描īŧŒé›ļåŸēįĄ€čŽžæ–Ŋ。</em></p>
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://docs.telepat.io/snoopy">📖 æ–‡æĄŖ</a>
@@ -15,17 +15,23 @@
15
15
  <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>
16
16
  </p>
17
17
 
18
- Snoopy 帎劊äŊ į›‘控 RedditīŧŒæ‰žåˆ°ä¸Žä¸šåŠĄį›Žæ ‡é̘åēĻį›¸å…ŗįš„å¯šč¯ã€‚
18
+ Snoopy į›‘æŽ§åœ¨įēŋå¯šč¯ä¸­įš„éĢ˜æ„å‘äŋĄåˇīŧŒåŒšé…æ‚¨įš„ä¸šåŠĄį›Žæ ‡ã€‚
19
19
 
20
- ᔍč‡Ēį„ļč¯­č¨€åŽšäš‰äŊ å…ŗåŋƒįš„内厚īŧŒčŽŠ Snoopy 创åģēį›‘æŽ§äģģåŠĄīŧŒį„ļ后持įģ­æ‰Ģæå’Œč¯„äŧ°å¸–å­å’Œč¯„čŽēīŧŒčŽŠäŊ ä¸“æŗ¨äēŽå›žå¤å’Œå¤–č”ã€‚
20
+ ᔍč‡Ēį„ļč¯­č¨€åŽšäš‰æ‚¨å…ŗåŋƒįš„内厚īŧŒčŽŠ Snoopy 创åģēį›‘æŽ§äģģåŠĄīŧŒæŒįģ­æ‰Ģæå’Œč¯„äŧ°å¯šč¯īŧŒčŽŠæ‚¨ä¸“æŗ¨äēŽå›žå¤å’Œå¤–č”ã€‚
21
21
 
22
- ## 厃čƒŊ觪冺äģ€äšˆé—Žéĸ˜
22
+ 专ä¸ē需čĻå‘įŽ°įœŸæ­Ŗæœēäŧšč€Œæ— éœ€æ‰‹åЍæĩč§ˆåœ¨įēŋį¤žåŒēįš„åˆ›å§‹äēē、čĨ销äēē员和销唎å›ĸ队打造。
23
23
 
24
- - 将åšŋæŗ›įš„ Reddit æĩé‡čŊŦ化ä¸ē聚į„Ļįš„æœēäŧšæĩã€‚
25
- - 一æŦĄæ€§åŽšäš‰č¯„äŧ°é€ģ辑īŧŒį„ļ后持įģ­čŋčĄŒã€‚
26
- - 需čρåŋĢ速énj蝁æ—ļč§Ļ发手动čŋčĄŒã€‚
27
- - 莟č¸ĒčŋčĄŒåˆ†æžæ•°æŽīŧˆå‘įŽ°ã€æ–°åĸžã€įŦĻåˆæĄäģļįš„éĄšį›Žã€token į”¨é‡ã€æˆæœŦäŧ°įŽ—īŧ‰ã€‚
28
- - čˇ¨åšŗå°čŋčĄŒīŧŒæ”¯æŒåŧ€æœēč‡Ē启。
24
+ ## 功čƒŊį‰šæ€§
25
+
26
+ - **č‡Ēį„ļ蝭荀äģģåŠĄåˆ›åģē** — ᔍč‡Ēį„ļč¯­č¨€æčŋ°æ‚¨æ­Ŗåœ¨å¯ģæ‰žįš„å†…åŽšã€‚Snoopy 构åģē AI čž…åŠŠįš„į›‘æŽ§äģģåŠĄã€‚æ— éœ€æ­Ŗåˆ™čĄ¨čžžåŧīŧŒæ— éœ€å…ŗé”Žå­—配įŊŽã€‚
27
+ - **AI 蝄äŧ°īŧŒč€Œéžå…ŗé”Žč¯åŒšé…** — å¯šč¯äŧšå¯šį…§æ‚¨įš„æ„å›žčŋ›čĄŒč¯„äŧ°ã€‚Snoopy į†č§Ŗä¸Šä¸‹æ–‡â€”â€”ä¸äģ…äģ…æ˜¯æ¨ĄåŧåŒšé…ã€‚
28
+ - **反éĻˆéŠąåŠ¨įš„æį¤ēč¯čŋ›åŒ–** — åŽĄé˜…į쓿žœã€æä礿œ‰æ•ˆ/无效反éψīŧŒåšļæ‰§čĄŒ consolidateīŧŒčŽŠč¯„äŧ°æį¤ēč¯éšæ—ļ间持įģ­äŧ˜åŒ–。
29
+ - **持įģ­åŽˆæŠ¤čŋ›į¨‹į›‘控** — 莞įŊŽ cron čŽĄåˆ’īŧŒčŽŠ Snoopy 在后台æ‰Ģ描。`snoopy daemon start`
30
+ - **äģŖį éŠąåŠ¨įš„éĢ˜æ•ˆįŽ‡** — įĄŽåŽšæ€§äģŖį å¤„į†æ•°æŽæŠ“å–ã€č°ƒåēĻ、įŠļæ€įŽĄį†å’Œ SQLite 持䚅化。Token äģ…ᔍäēŽč¯„äŧ°ã€‚
31
+ - **æœŦåœ°åŒ–ä¸Žéšį§äŋæŠ¤** — SQLite 数捎åē“å­˜å‚¨åœ¨æ‚¨įš„æœē器上。无äē‘䞝čĩ–。按需å¯ŧå‡ēä¸ē CSV 或 JSON。
32
+ - **成æœŦ感įŸĨ分析** — 每æŦĄčŋčĄŒįš„ Token äŊŋį”¨é‡ã€æˆæœŦäŧ°įŽ—å’ŒįŦĻåˆæĄäģļįš„éĄšį›Žã€‚`snoopy analytics --days 7`
33
+ - **æ™ēčƒŊäŊ“与 CI å°ąįģĒ** — MCP æœåŠĄå™¨ã€į›´æŽĨ SQLite čŽŋ闎、非äē¤äē’æ¨Ąåŧã€æœē器可č¯ģ输å‡ē。
34
+ - **čˇ¨åšŗå°** — macOS、Linux、Windows。支持åŧ€æœēč‡Ē启。`snoopy startup install`
29
35
 
30
36
  ## åŋĢ速åŧ€å§‹
31
37
 
@@ -78,10 +84,29 @@ Snoopy 专ä¸ēæ— į•Œéĸč‡Ē动化和æ™ēčƒŊäŊ“éŠąåŠ¨įš„į›‘æŽ§čŽžčŽĄīŧš
78
84
 
79
85
  - **非äē¤äē’åŧ CLI** — 大多数å‘Ŋä줿”¯æŒįœį•Ĩ `<jobRef>` äģĨäē¤äē’åŧé€‰æ‹ŠīŧŒäŊ†č‡Ē动化可äģĨį›´æŽĨäŧ å…Ĩ ref åŽžįŽ°é›ļ提į¤ēæ‰§čĄŒã€‚
80
86
  - **æœē器可č¯ģ输å‡ē** — `snoopy export --json --last-run` 和 `snoopy consume --json` į”Ÿæˆį쓿ž„化数捎īŧŒäž›ä¸‹æ¸¸æ™ēčƒŊäŊ“æļˆč´šã€‚
87
+ - **持įģ­č´¨é‡åéĻˆé—­įŽ¯** — æ™ēčƒŊäŊ“å¯æ‰§čĄŒ `snoopy feedback review --json`īŧŒæ”ļ集äēēåˇĨ反éĻˆåŽč°ƒį”¨ `snoopy feedback submit`īŧŒæœ€åŽæ‰§čĄŒ `snoopy feedback consolidate`。
81
88
  - **į›´æŽĨ数捎åē“čŽŋ问** — SQLite äŊäēŽ `~/.snoopy/snoopy.db`īŧˆæˆ– `$SNOOPY_ROOT_DIR/snoopy.db`īŧ‰īŧŒæ‹Ĩæœ‰åŽŒæ•´æ–‡æĄŖåŒ–įš„ schema。æ™ēčƒŊäŊ“可äģĨį›´æŽĨ插å…ĨäģģåŠĄã€æŸĨč¯ĸį쓿žœåšļæ›´æ–°į”Ÿå‘Ŋ周期标åŋ—。
82
89
  - **įŽ¯åĸƒå˜é‡** — `SNOOPY_OPENROUTER_API_KEY`、`SNOOPY_REDDIT_CLIENT_SECRET` 和 `SNOOPY_ROOT_DIR` 可į§ģ除所有äē¤äē’åŧå‡­č¯æį¤ē。
83
90
  - **Agent æ–‡æĄŖ** — [Agent Operations](https://docs.telepat.io/snoopy/guides/agent-operations) æäž›åŽŒæ•´įš„č‡Ē动化手册īŧŒåŒ…æ‹Ŧ SQL schemaã€į”Ÿå‘Ŋ周期标åŋ—å’ŒæŽ¨čåˇĨäŊœæĩã€‚
84
91
 
92
+ ## 反éψåˇĨäŊœæĩ
93
+
94
+ äŊŋį”¨åéψå‘Ŋä줿Œįģ­æå‡č¯„äŧ°č´¨é‡īŧš
95
+
96
+ ```bash
97
+ # 1īŧ‰åŽĄé˜…æœĒéĒŒč¯įš„åˆæ ŧį쓿žœīŧˆé€‚合æ™ēčƒŊäŊ“įš„ JSONīŧ‰
98
+ snoopy feedback review --json --limit 10
99
+
100
+ # 2īŧ‰é€æĄæäē¤åéψ
101
+ snoopy feedback submit <resultId> --valid
102
+ snoopy feedback submit <resultId> --invalid --reason "čŋ™ä¸æ˜¯åŽžé™…č´­äš°æ„å›ž"
103
+
104
+ # 3īŧ‰åˆåšļ反éψåšļæ›´æ–°č¯„äŧ°æį¤ēč¯
105
+ snoopy feedback consolidate
106
+ ```
107
+
108
+ 在äē¤äē’åŧ `snoopy feedback review` 中īŧŒč‹Ĩ提前退å‡ēäŧšæį¤ē是åĻå…ˆæ‰§čĄŒ consolidate。
109
+
85
110
  ## 厉全与äŋĄäģģ
86
111
 
87
112
  - 密é’Ĩéģ˜čޤäŋå­˜åœ¨ OS é’ĨåŒ™ä¸˛ä¸­īŧˆé€ščŋ‡ `keytar`īŧ‰ã€‚åĻ‚æžœé’ĨåŒ™ä¸˛ä¸å¯į”¨īŧŒåˆ™å›žé€€åˆ°åŠ å¯†æ–‡äģļ。
@@ -0,0 +1,18 @@
1
+ interface FeedbackSubmitOptions {
2
+ valid?: boolean;
3
+ invalid?: boolean;
4
+ reason?: string;
5
+ json?: boolean;
6
+ }
7
+ interface FeedbackReviewOptions {
8
+ json?: boolean;
9
+ limit?: number;
10
+ }
11
+ interface FeedbackConsolidateOptions {
12
+ limit?: number;
13
+ json?: boolean;
14
+ }
15
+ export declare function feedbackSubmit(resultId: string, options?: FeedbackSubmitOptions): Promise<void>;
16
+ export declare function feedbackReview(jobRef?: string, options?: FeedbackReviewOptions): Promise<void>;
17
+ export declare function feedbackConsolidate(jobRef?: string, options?: FeedbackConsolidateOptions): Promise<void>;
18
+ export {};
@@ -0,0 +1,276 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { diff } from 'jest-diff';
3
+ import { JobsRepository } from '../../services/db/repositories/jobsRepo.js';
4
+ import { ScanItemsRepository } from '../../services/db/repositories/scanItemsRepo.js';
5
+ import { consolidateFeedback } from '../../services/feedback/consolidationService.js';
6
+ import { isRichTty, printCommandScreen, printError, printInfo, printKeyValue, printMuted, printSection, printSuccess, printWarning, } from '../ui/consoleUi.js';
7
+ const MAX_DIFF_OUTPUT_LINES = 120;
8
+ function formatPromptDiff(oldPrompt, newPrompt) {
9
+ const output = diff(oldPrompt, newPrompt, {
10
+ aAnnotation: 'old prompt',
11
+ bAnnotation: 'new prompt',
12
+ expand: false,
13
+ });
14
+ if (!output) {
15
+ return null;
16
+ }
17
+ const lines = output.split('\n');
18
+ if (lines.length <= MAX_DIFF_OUTPUT_LINES) {
19
+ return { text: output, truncated: false };
20
+ }
21
+ return {
22
+ text: lines.slice(0, MAX_DIFF_OUTPUT_LINES).join('\n'),
23
+ truncated: true,
24
+ };
25
+ }
26
+ async function promptLine(message) {
27
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
28
+ try {
29
+ return (await rl.question(message)).trim();
30
+ }
31
+ finally {
32
+ rl.close();
33
+ }
34
+ }
35
+ async function promptValidity() {
36
+ while (true) {
37
+ const answer = (await promptLine('Mark as (v)alid, (i)nvalid, or (q)uit: ')).toLowerCase();
38
+ if (answer === 'v' || answer === 'valid') {
39
+ return 'valid';
40
+ }
41
+ if (answer === 'i' || answer === 'invalid') {
42
+ return 'invalid';
43
+ }
44
+ if (answer === 'q' || answer === 'quit' || answer === 'exit') {
45
+ return 'quit';
46
+ }
47
+ printWarning('Please enter v, i, or q.');
48
+ }
49
+ }
50
+ async function promptRequiredReason() {
51
+ while (true) {
52
+ const answer = await promptLine('Reason (required for invalid feedback): ');
53
+ if (answer.length > 0) {
54
+ return answer;
55
+ }
56
+ printWarning('A reason is required when marking a result as invalid.');
57
+ }
58
+ }
59
+ async function promptConsolidateBeforeExit() {
60
+ const answer = (await promptLine('Run consolidate now before exiting? [Y/n]: ')).toLowerCase();
61
+ if (!answer) {
62
+ return true;
63
+ }
64
+ return answer !== 'n' && answer !== 'no';
65
+ }
66
+ function validateSubmitFlags(options) {
67
+ const hasValid = options.valid === true;
68
+ const hasInvalid = options.invalid === true;
69
+ if (hasValid === hasInvalid) {
70
+ throw new Error('Choose exactly one: --valid or --invalid.');
71
+ }
72
+ if (hasInvalid) {
73
+ const reason = options.reason?.trim() ?? '';
74
+ if (!reason) {
75
+ throw new Error('A reason is required when using --invalid.');
76
+ }
77
+ return { isValid: false, reason };
78
+ }
79
+ return { isValid: true, reason: null };
80
+ }
81
+ function printThread(commentThreadNodes) {
82
+ if (commentThreadNodes.length === 0) {
83
+ printWarning('Thread unavailable for this comment.');
84
+ return;
85
+ }
86
+ printSection('Thread (root -> target)');
87
+ commentThreadNodes.forEach((node, index) => {
88
+ const marker = node.isTarget ? 'target' : `depth ${node.depth}`;
89
+ printInfo(`${index + 1}. ${node.author} (${marker})`);
90
+ printMuted(` ${node.body}`);
91
+ });
92
+ }
93
+ function printReviewItem(row, commentThreadNodes) {
94
+ printSection(`Result ${row.id}`);
95
+ printKeyValue('Type', row.type);
96
+ printKeyValue('Subreddit', `r/${row.subreddit}`);
97
+ printKeyValue('Author', row.author);
98
+ printKeyValue('URL', row.url);
99
+ printKeyValue('Posted', row.redditPostedAt);
100
+ if (row.title) {
101
+ printKeyValue('Title', row.title);
102
+ }
103
+ if (row.qualificationReason) {
104
+ printKeyValue('Qualification reason', row.qualificationReason);
105
+ }
106
+ if (row.type === 'comment') {
107
+ printThread(commentThreadNodes);
108
+ }
109
+ printSection('Content');
110
+ printMuted(row.body);
111
+ }
112
+ export async function feedbackSubmit(resultId, options = {}) {
113
+ printCommandScreen('Feedback', 'Submit feedback');
114
+ const scanItemsRepo = new ScanItemsRepository();
115
+ const row = scanItemsRepo.getQualifiedById(resultId);
116
+ if (!row) {
117
+ throw new Error(`Qualified result not found: ${resultId}`);
118
+ }
119
+ const { isValid, reason } = validateSubmitFlags(options);
120
+ const updated = scanItemsRepo.submitFeedback(resultId, isValid, reason);
121
+ if (!updated) {
122
+ throw new Error(`Failed to save feedback for result: ${resultId}`);
123
+ }
124
+ const pendingCount = scanItemsRepo.countPendingFeedbackConsolidation(row.jobId);
125
+ const payload = {
126
+ resultId,
127
+ saved: true,
128
+ feedback: {
129
+ validated: true,
130
+ isValid,
131
+ isValidReason: reason,
132
+ feedbackConsolidated: false,
133
+ },
134
+ pendingFeedbackConsolidationCount: pendingCount,
135
+ requiresConsolidation: pendingCount > 0,
136
+ recommendedNextCommand: 'snoopy feedback consolidate',
137
+ };
138
+ if (options.json) {
139
+ console.log(JSON.stringify(payload, null, 2));
140
+ return;
141
+ }
142
+ printSuccess(`Saved feedback for result ${resultId}.`);
143
+ printKeyValue('Valid', isValid ? 'yes' : 'no');
144
+ if (!isValid && reason) {
145
+ printKeyValue('Reason', reason);
146
+ }
147
+ if (pendingCount > 0) {
148
+ printInfo(`Consolidation pending for ${pendingCount} result(s). Run snoopy feedback consolidate.`);
149
+ }
150
+ }
151
+ async function runInteractiveReview(rows, runConsolidateOnExit) {
152
+ const scanItemsRepo = new ScanItemsRepository();
153
+ let validatedCount = 0;
154
+ let invalidCount = 0;
155
+ let earlyExit = false;
156
+ for (let index = 0; index < rows.length; index += 1) {
157
+ const row = rows[index];
158
+ const commentThreadNodes = row.type === 'comment' ? scanItemsRepo.listCommentThreadNodes(row.id) : [];
159
+ printCommandScreen('Feedback review', `Item ${index + 1}/${rows.length}`);
160
+ printReviewItem(row, commentThreadNodes);
161
+ const verdict = await promptValidity();
162
+ if (verdict === 'quit') {
163
+ earlyExit = true;
164
+ break;
165
+ }
166
+ const isValid = verdict === 'valid';
167
+ const reason = isValid ? null : await promptRequiredReason();
168
+ scanItemsRepo.submitFeedback(row.id, isValid, reason);
169
+ validatedCount += 1;
170
+ if (!isValid) {
171
+ invalidCount += 1;
172
+ }
173
+ printSuccess(`Saved feedback for ${row.id}.`);
174
+ }
175
+ printSection('Review summary');
176
+ printKeyValue('Validated this session', String(validatedCount));
177
+ printKeyValue('Marked invalid', String(invalidCount));
178
+ printKeyValue('Early exit', earlyExit ? 'yes' : 'no');
179
+ if (earlyExit) {
180
+ const shouldConsolidateNow = await promptConsolidateBeforeExit();
181
+ if (shouldConsolidateNow) {
182
+ await runConsolidateOnExit();
183
+ }
184
+ else {
185
+ printWarning('Skipping consolidate. Prompt quality will not improve until consolidate runs.');
186
+ }
187
+ return;
188
+ }
189
+ await runConsolidateOnExit();
190
+ }
191
+ export async function feedbackReview(jobRef, options = {}) {
192
+ const jobsRepo = new JobsRepository();
193
+ const scanItemsRepo = new ScanItemsRepository();
194
+ let jobId;
195
+ if (jobRef) {
196
+ const job = jobsRepo.getByRef(jobRef);
197
+ if (!job) {
198
+ throw new Error(`Job not found: ${jobRef}`);
199
+ }
200
+ jobId = job.id;
201
+ }
202
+ const limit = options.limit ?? 10;
203
+ const rows = scanItemsRepo.listUnvalidatedQualified(jobId, limit);
204
+ if (options.json) {
205
+ console.log(JSON.stringify(rows, null, 2));
206
+ return;
207
+ }
208
+ printCommandScreen('Feedback', 'Review feedback queue');
209
+ if (rows.length === 0) {
210
+ printInfo('No more results to validate. Exiting review.');
211
+ return;
212
+ }
213
+ if (!isRichTty()) {
214
+ printError('Interactive review requires a rich terminal. Use --json in non-interactive mode.');
215
+ return;
216
+ }
217
+ const runConsolidateOnExit = async () => {
218
+ printInfo('Running feedback consolidation...');
219
+ const result = await consolidateFeedback({ jobRef, limit });
220
+ printSuccess(`Consolidated ${result.totalConsolidated} result(s).`);
221
+ if (result.requiresConsolidation) {
222
+ printWarning(`Still pending: ${result.totalPendingAfter}. Run snoopy feedback consolidate again.`);
223
+ }
224
+ };
225
+ await runInteractiveReview(rows, runConsolidateOnExit);
226
+ }
227
+ export async function feedbackConsolidate(jobRef, options = {}) {
228
+ printCommandScreen('Feedback', 'Consolidate feedback');
229
+ const result = await consolidateFeedback({
230
+ jobRef,
231
+ limit: options.limit,
232
+ });
233
+ if (options.json) {
234
+ console.log(JSON.stringify(result, null, 2));
235
+ return;
236
+ }
237
+ if (result.totalPendingBefore === 0) {
238
+ printInfo('No pending feedback to consolidate.');
239
+ return;
240
+ }
241
+ result.jobs.forEach((jobResult) => {
242
+ printInfo(`${jobResult.jobName} (${jobResult.jobSlug})`);
243
+ printKeyValue('Pending', String(jobResult.pendingCount));
244
+ printKeyValue('Consolidated', String(jobResult.consolidatedCount));
245
+ if (jobResult.error) {
246
+ printError(jobResult.error);
247
+ return;
248
+ }
249
+ if (jobResult.changeSummary && jobResult.changeSummary.length > 0) {
250
+ printKeyValue('Changes', jobResult.changeSummary.join(' | '));
251
+ }
252
+ if (!jobResult.promptUpdated || !jobResult.oldPrompt || !jobResult.newPrompt) {
253
+ return;
254
+ }
255
+ if (jobResult.oldPrompt === jobResult.newPrompt) {
256
+ printMuted('Prompt did not change.');
257
+ return;
258
+ }
259
+ const promptDiff = formatPromptDiff(jobResult.oldPrompt, jobResult.newPrompt);
260
+ if (!promptDiff) {
261
+ printMuted('Prompt updated, but no textual diff was produced.');
262
+ return;
263
+ }
264
+ printSection(`Prompt diff (${jobResult.jobSlug})`);
265
+ console.log(promptDiff.text);
266
+ if (promptDiff.truncated) {
267
+ printWarning(`Prompt diff truncated to ${MAX_DIFF_OUTPUT_LINES} lines.`);
268
+ }
269
+ });
270
+ if (result.requiresConsolidation) {
271
+ printWarning(`Consolidation incomplete. ${result.totalPendingAfter} result(s) still pending.`);
272
+ return;
273
+ }
274
+ printSuccess(`Consolidated ${result.totalConsolidated} result(s) across ${result.jobs.length} job(s).`);
275
+ }
276
+ //# sourceMappingURL=feedback.js.map
@@ -0,0 +1,6 @@
1
+ interface ShowPromptOptions {
2
+ raw?: boolean;
3
+ }
4
+ export declare function showPrompt(jobRef: string, options?: ShowPromptOptions): Promise<void>;
5
+ export declare function setPrompt(jobRef: string, promptText: string): Promise<void>;
6
+ export {};
@@ -0,0 +1,92 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { JobsRepository } from '../../services/db/repositories/jobsRepo.js';
3
+ import { isRichTty, printCommandScreen, printError, printInfo, printKeyValue, printMuted, printSection, printSuccess, printWarning } from '../ui/consoleUi.js';
4
+ import { editPromptInteractively } from './promptEditor.js';
5
+ async function promptLine(message) {
6
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
7
+ try {
8
+ return (await rl.question(message)).trim();
9
+ }
10
+ finally {
11
+ rl.close();
12
+ }
13
+ }
14
+ async function confirmEditPrompt() {
15
+ const answer = (await promptLine('Edit qualification prompt now? [y/N]: ')).toLowerCase();
16
+ if (!answer) {
17
+ return false;
18
+ }
19
+ return answer === 'y' || answer === 'yes';
20
+ }
21
+ function printPromptEnvelope(job) {
22
+ printKeyValue('Job', `${job.name} (${job.slug})`);
23
+ printKeyValue('Job ID', job.id);
24
+ printKeyValue('Updated', job.updatedAt);
25
+ printSection('Qualification prompt');
26
+ printMuted(job.qualificationPrompt);
27
+ }
28
+ export async function showPrompt(jobRef, options = {}) {
29
+ const jobsRepo = new JobsRepository();
30
+ const job = jobsRepo.getByRef(jobRef);
31
+ if (!job) {
32
+ printError(`Job not found: ${jobRef}`);
33
+ return;
34
+ }
35
+ if (options.raw) {
36
+ process.stdout.write(`${job.qualificationPrompt}\n`);
37
+ return;
38
+ }
39
+ printCommandScreen('Prompt', 'View Prompt');
40
+ printPromptEnvelope(job);
41
+ if (!isRichTty()) {
42
+ printInfo('Use --raw for non-interactive prompt retrieval.');
43
+ return;
44
+ }
45
+ const shouldEdit = await confirmEditPrompt();
46
+ if (!shouldEdit) {
47
+ printInfo('Prompt unchanged.');
48
+ return;
49
+ }
50
+ const updatedPrompt = await editPromptInteractively(job.qualificationPrompt);
51
+ if (updatedPrompt === null) {
52
+ printWarning('Prompt edit cancelled.');
53
+ return;
54
+ }
55
+ const normalizedPrompt = updatedPrompt.trim();
56
+ if (!normalizedPrompt) {
57
+ printError('Prompt cannot be empty.');
58
+ return;
59
+ }
60
+ if (normalizedPrompt === job.qualificationPrompt.trim()) {
61
+ printInfo('Prompt unchanged.');
62
+ return;
63
+ }
64
+ const updated = jobsRepo.updateQualificationPromptByRef(job.id, normalizedPrompt);
65
+ if (!updated) {
66
+ printError(`Failed to update prompt for job: ${job.id}`);
67
+ return;
68
+ }
69
+ printSuccess(`Updated qualification prompt for ${updated.name} (${updated.slug}).`);
70
+ }
71
+ export async function setPrompt(jobRef, promptText) {
72
+ const normalizedPrompt = promptText.trim();
73
+ if (!normalizedPrompt) {
74
+ throw new Error('Prompt cannot be empty.');
75
+ }
76
+ const jobsRepo = new JobsRepository();
77
+ const existing = jobsRepo.getByRef(jobRef);
78
+ if (!existing) {
79
+ printError(`Job not found: ${jobRef}`);
80
+ return;
81
+ }
82
+ const updated = jobsRepo.updateQualificationPromptByRef(existing.id, normalizedPrompt);
83
+ if (!updated) {
84
+ printError(`Failed to update prompt for job: ${existing.id}`);
85
+ return;
86
+ }
87
+ printCommandScreen('Prompt', 'Set Prompt');
88
+ printSuccess(`Updated qualification prompt for ${updated.name} (${updated.slug}).`);
89
+ printKeyValue('Job ID', updated.id);
90
+ printKeyValue('Updated', updated.updatedAt);
91
+ }
92
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1 @@
1
+ export declare function editPromptInteractively(initialValue: string): Promise<string | null>;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { AppFrame, Panel } from '../../ui/components/AppFrame.js';
4
+ import { MultilinePrompt } from '../../ui/components/MultilinePrompt.js';
5
+ export async function editPromptInteractively(initialValue) {
6
+ let result = null;
7
+ const app = render(_jsx(AppFrame, { subtitle: "Prompt", statusText: "Interactive editor", statusTone: "info", hints: ['Enter submit', 'Shift+Enter newline', 'Esc cancel'], children: _jsx(Panel, { title: "Edit Qualification Prompt", children: _jsx(MultilinePrompt, { label: "Qualification prompt", initialValue: initialValue, rows: 10, onSubmit: (value) => {
8
+ result = value;
9
+ app.unmount();
10
+ }, onCancel: () => {
11
+ result = null;
12
+ app.unmount();
13
+ } }) }) }));
14
+ await app.waitUntilExit();
15
+ return result;
16
+ }
17
+ //# sourceMappingURL=promptEditor.js.map
@@ -128,7 +128,7 @@ export function JobAddFlow({ hasApiKey, existingApiKey, canPersistApiKey, startu
128
128
  } })] }) }));
129
129
  }
130
130
  if (stage === 'model') {
131
- return (_jsx(FlowFrame, { transcript: transcript, statusText: "Choose model", statusTone: "info", children: _jsxs(Panel, { title: "Step 3: Model", children: [_jsx(Text, { color: uiTheme.ink.warning, children: "Model selection (default moonshotai/kimi-k2.5)" }), _jsx(TextPrompt, { label: "Model ID", initialValue: model.trim() || defaultModel, onSubmit: (value) => {
131
+ return (_jsx(FlowFrame, { transcript: transcript, statusText: "Choose model", statusTone: "info", children: _jsxs(Panel, { title: "Step 3: Model", children: [_jsx(Text, { color: uiTheme.ink.warning, children: "Model selection (default deepseek/deepseek-v4-pro)" }), _jsx(TextPrompt, { label: "Model ID", initialValue: model.trim() || defaultModel, onSubmit: (value) => {
132
132
  const chosenModel = value.trim() || defaultModel;
133
133
  setModel(chosenModel);
134
134
  appendTranscript('Model', chosenModel);
@@ -8,8 +8,10 @@ import { showRunLogs } from './commands/logs.js';
8
8
  import { showJobErrors } from './commands/errors.js';
9
9
  import { exportCsv } from './commands/export.js';
10
10
  import { consumeResults } from './commands/consume.js';
11
+ import { feedbackConsolidate, feedbackReview, feedbackSubmit } from './commands/feedback.js';
11
12
  import { showAnalytics } from './commands/analytics.js';
12
13
  import { showResults } from './commands/results.js';
14
+ import { showPrompt, setPrompt } from './commands/prompt.js';
13
15
  import { disableStartupCommand, enableStartupCommand, installStartupCommand, startupStatusCommand, uninstallStartupCommand } from './commands/startup.js';
14
16
  import { runDoctor } from './commands/doctor.js';
15
17
  import { ensureAppDirs } from '../utils/paths.js';
@@ -38,7 +40,7 @@ function parsePositiveInteger(value) {
38
40
  return parsed;
39
41
  }
40
42
  const program = new Command();
41
- program.name('snoopy').description('Monitor Reddit conversations with natural language job definitions.').version(readVersion());
43
+ program.name('snoopy').description('Monitor online conversations for high-intent signals with AI — plain language criteria, continuous scanning, zero infrastructure.').version(readVersion());
42
44
  const job = program.command('job').description('Manage monitoring jobs');
43
45
  job.command('add').description('Add a monitoring job').action(async () => {
44
46
  await addJob();
@@ -153,6 +155,52 @@ program
153
155
  .action((jobRef, options) => {
154
156
  consumeResults(jobRef, options);
155
157
  });
158
+ const feedback = program.command('feedback').description('Collect qualification feedback and consolidate prompt improvements');
159
+ feedback
160
+ .command('review')
161
+ .argument('[jobRef]', 'Optional job ID or slug')
162
+ .description('Review unvalidated qualified results and submit feedback')
163
+ .option('--json', 'Output raw JSON array to stdout')
164
+ .option('--limit <count>', 'Maximum number of results to review (default: 10)', parsePositiveInteger, 10)
165
+ .action(async (jobRef, options) => {
166
+ await feedbackReview(jobRef, options);
167
+ });
168
+ feedback
169
+ .command('submit')
170
+ .argument('<resultId>', 'Qualified result ID')
171
+ .description('Submit validity feedback for a qualified result')
172
+ .option('--valid', 'Mark this result as valid')
173
+ .option('--invalid', 'Mark this result as invalid')
174
+ .option('--reason <text>', 'Reason for invalid feedback (required with --invalid)')
175
+ .option('--json', 'Output machine-readable JSON status')
176
+ .action(async (resultId, options) => {
177
+ await feedbackSubmit(resultId, options);
178
+ });
179
+ feedback
180
+ .command('consolidate')
181
+ .argument('[jobRef]', 'Optional job ID or slug')
182
+ .description('Consolidate user feedback into improved qualification prompts')
183
+ .option('--limit <count>', 'Maximum pending feedback items to process', parsePositiveInteger)
184
+ .option('--json', 'Output machine-readable JSON status')
185
+ .action(async (jobRef, options) => {
186
+ await feedbackConsolidate(jobRef, options);
187
+ });
188
+ const prompt = program.command('prompt').description('View and update job qualification prompts');
189
+ prompt
190
+ .argument('<jobRef>', 'Job ID or slug')
191
+ .description('View the qualification prompt for a specific job')
192
+ .option('--raw', 'Output only the prompt text and exit')
193
+ .action(async (jobRef, options) => {
194
+ await showPrompt(jobRef, options);
195
+ });
196
+ prompt
197
+ .command('set')
198
+ .argument('<jobRef>', 'Job ID or slug')
199
+ .argument('<prompt>', 'New qualification prompt text')
200
+ .description('Set a new qualification prompt for a specific job')
201
+ .action(async (jobRef, promptText) => {
202
+ await setPrompt(jobRef, promptText);
203
+ });
156
204
  // --- MCP server ---
157
205
  program
158
206
  .command('mcp')
@@ -37,6 +37,9 @@ export declare function runJobReport(jobRef: string, limit?: number): Record<str
37
37
  export declare function analyticsReport(jobRef?: string, days?: number): Record<string, unknown>;
38
38
  export declare function exportReport(jobRef?: string, format?: string, lastRun?: boolean, limit?: number): Record<string, unknown>;
39
39
  export declare function consumeReport(jobRef?: string, limit?: number, dryRun?: boolean): Record<string, unknown>;
40
+ export declare function feedbackReviewReport(jobRef?: string, limit?: number): Record<string, unknown>;
41
+ export declare function feedbackSubmitReport(resultId: string, isValid: boolean, reason?: string): Record<string, unknown>;
42
+ export declare function feedbackConsolidateReport(jobRef?: string, limit?: number): Promise<Record<string, unknown>>;
40
43
  export declare function errorsReport(jobRef: string, hours?: number): Record<string, unknown>;
41
44
  export declare function logsReport(runId: string): Record<string, unknown>;
42
45
  export declare function settingsGetReport(): Promise<Record<string, unknown>>;