@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.
- package/README.md +34 -9
- package/README.zh-CN.md +34 -9
- package/dist/src/cli/commands/feedback.d.ts +18 -0
- package/dist/src/cli/commands/feedback.js +276 -0
- package/dist/src/cli/commands/prompt.d.ts +6 -0
- package/dist/src/cli/commands/prompt.js +92 -0
- package/dist/src/cli/commands/promptEditor.d.ts +1 -0
- package/dist/src/cli/commands/promptEditor.js +17 -0
- package/dist/src/cli/flows/jobAddFlow.js +1 -1
- package/dist/src/cli/index.js +49 -1
- package/dist/src/mcp/helpers.d.ts +3 -0
- package/dist/src/mcp/helpers.js +78 -5
- package/dist/src/mcp/server.js +41 -2
- package/dist/src/mcp/tools.d.ts +13 -0
- package/dist/src/mcp/tools.js +16 -0
- package/dist/src/services/db/migrations/002_feedback_fields.d.ts +7 -0
- package/dist/src/services/db/migrations/002_feedback_fields.js +22 -0
- package/dist/src/services/db/migrations/index.js +2 -1
- package/dist/src/services/db/repositories/jobsRepo.d.ts +2 -0
- package/dist/src/services/db/repositories/jobsRepo.js +15 -0
- package/dist/src/services/db/repositories/scanItemsRepo.d.ts +17 -0
- package/dist/src/services/db/repositories/scanItemsRepo.js +197 -2
- package/dist/src/services/feedback/consolidationService.d.ts +28 -0
- package/dist/src/services/feedback/consolidationService.js +124 -0
- package/dist/src/services/openrouter/client.d.ts +23 -0
- package/dist/src/services/openrouter/client.js +67 -0
- package/dist/src/types/settings.d.ts +1 -1
- package/dist/src/types/settings.js +1 -1
- package/dist/src/ui/components/MultilinePrompt.d.ts +10 -0
- package/dist/src/ui/components/MultilinePrompt.js +87 -0
- package/dist/src/ui/components/multilinePromptModel.d.ts +25 -0
- package/dist/src/ui/components/multilinePromptModel.js +76 -0
- 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>
|
|
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
|
|
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
|
|
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
|
-
|
|
22
|
+
Built for founders, marketers, and sales teams who need to find genuine opportunities in online communities without manually scrolling.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
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
|
|
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
|
|
18
|
+
Snoopy įæ§å¨įēŋ寚č¯ä¸įéĢæåäŋĄåˇīŧåšé
æ¨įä¸åĄįŽæ ã
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
į¨čĒįļč¯č¨åŽäšæ¨å
ŗåŋįå
厚īŧ莊 Snoopy ååģēįæ§äģģåĄīŧæįģæĢæåč¯äŧ°å¯šč¯īŧ莊æ¨ä¸æŗ¨äēåå¤åå¤čã
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
ä¸ä¸ēéčĻåį°įæŖæēäŧčæ éæå¨æĩč§å¨įēŋį¤žåēįåå§äēēãčĨéäēēååéåŽåĸéæé ã
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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,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
|
|
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);
|
package/dist/src/cli/index.js
CHANGED
|
@@ -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
|
|
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>>;
|